feat: oidc support OIDC_LOGOUT_URL

This commit is contained in:
yusing 2025-01-24 00:13:45 +08:00
parent 7dd00d2424
commit 648fd23a57
10 changed files with 65 additions and 22 deletions

View file

@ -46,7 +46,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
} }
}) })
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler) mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", auth.LogoutCallbackHandler(defaultAuth)) mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
} else { } else {
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"slices" "slices"
"time" "time"
@ -23,6 +24,7 @@ type OIDCProvider struct {
oauthConfig *oauth2.Config oauthConfig *oauth2.Config
oidcProvider *oidc.Provider oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier oidcVerifier *oidc.IDTokenVerifier
oidcLogoutURL *url.URL
allowedUsers []string allowedUsers []string
allowedGroups []string allowedGroups []string
isMiddleware bool isMiddleware bool
@ -35,11 +37,20 @@ const (
OIDCLogoutPath = "/auth/logout" OIDCLogoutPath = "/auth/logout"
) )
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) { func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL, logoutURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
if len(allowedUsers)+len(allowedGroups) == 0 { if len(allowedUsers)+len(allowedGroups) == 0 {
return nil, errors.New("OIDC users, groups, or both must not be empty") return nil, errors.New("OIDC users, groups, or both must not be empty")
} }
var logout *url.URL
var err error
if logoutURL != "" {
logout, err = url.Parse(logoutURL)
if err != nil {
return nil, fmt.Errorf("failed to parse logout URL: %w", err)
}
}
provider, err := oidc.NewProvider(context.Background(), issuerURL) provider, err := oidc.NewProvider(context.Background(), issuerURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err) return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
@ -57,6 +68,7 @@ func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allo
oidcVerifier: provider.Verifier(&oidc.Config{ oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: clientID, ClientID: clientID,
}), }),
oidcLogoutURL: logout,
allowedUsers: allowedUsers, allowedUsers: allowedUsers,
allowedGroups: allowedGroups, allowedGroups: allowedGroups,
}, nil }, nil
@ -69,6 +81,7 @@ func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
common.OIDCClientID, common.OIDCClientID,
common.OIDCClientSecret, common.OIDCClientSecret,
common.OIDCRedirectURL, common.OIDCRedirectURL,
common.OIDCLogoutURL,
common.OIDCAllowedUsers, common.OIDCAllowedUsers,
common.OIDCAllowedGroups, common.OIDCAllowedGroups,
) )
@ -222,6 +235,25 @@ func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, "/", http.StatusTemporaryRedirect) http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
} }
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
if auth.oidcLogoutURL == nil {
DefaultLogoutCallbackHandler(auth, w, r)
return
}
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
U.HandleErr(w, r, E.New("missing token cookie"), http.StatusBadRequest)
return
}
clearTokenCookie(w, r, auth.TokenCookieName())
logoutURL := *auth.oidcLogoutURL
logoutURL.Query().Add("id_token_hint", token.Value)
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
}
// handleTestCallback handles OIDC callback in test environment. // handleTestCallback handles OIDC callback in test environment.
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) { func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(CookieOauthState) state, err := r.Cookie(CookieOauthState)

View file

@ -115,7 +115,7 @@ func setupProvider(t *testing.T) *provider {
} }
} }
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint // buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint.
func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any { func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any {
t.Helper() t.Helper()
@ -257,17 +257,13 @@ func TestInitOIDC(t *testing.T) {
clientID string clientID string
clientSecret string clientSecret string
redirectURL string redirectURL string
logoutURL string
allowedUsers []string allowedUsers []string
allowedGroups []string allowedGroups []string
wantErr bool wantErr bool
}{ }{
{ {
name: "Fail - Empty configuration", name: "Fail - Empty configuration",
issuerURL: "",
clientID: "",
clientSecret: "",
redirectURL: "",
allowedUsers: nil,
wantErr: true, wantErr: true,
}, },
{ {
@ -288,6 +284,17 @@ func TestInitOIDC(t *testing.T) {
allowedGroups: []string{"group1", "group2"}, allowedGroups: []string{"group1", "group2"},
wantErr: false, wantErr: false,
}, },
{
name: "Success - Valid configuration with users, groups and logout URL",
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
logoutURL: "https://example.com/logout",
allowedUsers: []string{"user1", "user2"},
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
{ {
name: "Fail - No allowed users or allowed groups", name: "Fail - No allowed users or allowed groups",
issuerURL: "https://example.com", issuerURL: "https://example.com",
@ -300,7 +307,7 @@ func TestInitOIDC(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.allowedUsers, tt.allowedGroups) _, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.logoutURL, tt.allowedUsers, tt.allowedGroups)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
} }

View file

@ -9,4 +9,5 @@ type Provider interface {
CheckToken(r *http.Request) error CheckToken(r *http.Request) error
RedirectLoginPage(w http.ResponseWriter, r *http.Request) RedirectLoginPage(w http.ResponseWriter, r *http.Request)
LoginCallbackHandler(w http.ResponseWriter, r *http.Request) LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
} }

View file

@ -128,6 +128,10 @@ func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Re
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
DefaultLogoutCallbackHandler(auth, w, r)
}
func (auth *UserPassAuth) validatePassword(user, pass string) error { func (auth *UserPassAuth) validatePassword(user, pass string) error {
if user != auth.username { if user != auth.username {
return ErrInvalidUsername.Subject(user) return ErrInvalidUsername.Subject(user)

View file

@ -62,9 +62,8 @@ func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
}) })
} }
func LogoutCallbackHandler(auth Provider) http.HandlerFunc { // DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
return func(w http.ResponseWriter, r *http.Request) { func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
clearTokenCookie(w, r, auth.TokenCookieName()) clearTokenCookie(w, r, auth.TokenCookieName())
auth.RedirectLoginPage(w, r) auth.RedirectLoginPage(w, r)
}
} }

View file

@ -51,6 +51,7 @@ var (
// OIDC Configuration. // OIDC Configuration.
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "") OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
OIDCLogoutURL = GetEnvString("OIDC_LOGOUT_URL", "")
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "") OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "") OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "") OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")

View file

@ -13,7 +13,6 @@ type oidcMiddleware struct {
auth auth.Provider auth auth.Provider
authMux *http.ServeMux authMux *http.ServeMux
logoutHandler http.HandlerFunc
} }
var OIDC = NewMiddleware[oidcMiddleware]() var OIDC = NewMiddleware[oidcMiddleware]()
@ -41,7 +40,6 @@ func (amw *oidcMiddleware) finalize() error {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
}) })
amw.authMux.HandleFunc("/", authProvider.RedirectLoginPage) amw.authMux.HandleFunc("/", authProvider.RedirectLoginPage)
amw.logoutHandler = auth.LogoutCallbackHandler(authProvider)
amw.auth = authProvider amw.auth = authProvider
return nil return nil
} }
@ -52,7 +50,7 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce
return false return false
} }
if r.URL.Path == auth.OIDCLogoutPath { if r.URL.Path == auth.OIDCLogoutPath {
amw.logoutHandler(w, r) amw.auth.LogoutCallbackHandler(w, r)
return false return false
} }
return true return true

View file

@ -130,7 +130,6 @@ func TestApplyLabel(t *testing.T) {
ExpectEqual(t, b.Container.StopSignal, "SIGTERM") ExpectEqual(t, b.Container.StopSignal, "SIGTERM")
ExpectEqual(t, a.Homepage.Show, true) ExpectEqual(t, a.Homepage.Show, true)
ExpectEqual(t, a.Homepage.Hide, false)
ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png") ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png")
ExpectEqual(t, a.Homepage.Icon.Extra.FileType, "png") ExpectEqual(t, a.Homepage.Icon.Extra.FileType, "png")
ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home") ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home")

View file

@ -92,6 +92,8 @@ GoDoxy v0.9.0 expected changes
- `GODOXY_OIDC_ISSUER_URL` e.g.: - `GODOXY_OIDC_ISSUER_URL` e.g.:
- Pocket ID: `https://pocker-id.yourdomain.com` - Pocket ID: `https://pocker-id.yourdomain.com`
- Authentik: `https://authentik.yourdomain.com/application/o/<application_slug>/` **The ending slash is required** - Authentik: `https://authentik.yourdomain.com/application/o/<application_slug>/` **The ending slash is required**
- `GODOXY_OIDC_LOGOUT_URL` _(if your issuer supports it, e.g.)_
- Authentik: `https://authentik.yourdomain.com/application/o/<application_slug>/end-session`
- `GODOXY_OIDC_CLIENT_ID` - `GODOXY_OIDC_CLIENT_ID`
- `GODOXY_OIDC_CLIENT_SECRET` - `GODOXY_OIDC_CLIENT_SECRET`
- `GODOXY_OIDC_REDIRECT_URL` - `GODOXY_OIDC_REDIRECT_URL`