diff --git a/internal/api/handler.go b/internal/api/handler.go index 32c54a1f..2048c3ee 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -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/logout", auth.LogoutCallbackHandler(defaultAuth)) + mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler) } else { mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/api/v1/auth/oidc.go b/internal/api/v1/auth/oidc.go index b55fa90f..d08d6746 100644 --- a/internal/api/v1/auth/oidc.go +++ b/internal/api/v1/auth/oidc.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "time" @@ -23,6 +24,7 @@ type OIDCProvider struct { oauthConfig *oauth2.Config oidcProvider *oidc.Provider oidcVerifier *oidc.IDTokenVerifier + oidcLogoutURL *url.URL allowedUsers []string allowedGroups []string isMiddleware bool @@ -35,11 +37,20 @@ const ( 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 { 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) if err != nil { 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{ ClientID: clientID, }), + oidcLogoutURL: logout, allowedUsers: allowedUsers, allowedGroups: allowedGroups, }, nil @@ -69,6 +81,7 @@ func NewOIDCProviderFromEnv() (*OIDCProvider, error) { common.OIDCClientID, common.OIDCClientSecret, common.OIDCRedirectURL, + common.OIDCLogoutURL, common.OIDCAllowedUsers, common.OIDCAllowedGroups, ) @@ -222,6 +235,25 @@ func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Re 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. func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) { state, err := r.Cookie(CookieOauthState) diff --git a/internal/api/v1/auth/oidc_test.go b/internal/api/v1/auth/oidc_test.go index d14715ea..4688dba1 100644 --- a/internal/api/v1/auth/oidc_test.go +++ b/internal/api/v1/auth/oidc_test.go @@ -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 { t.Helper() @@ -257,18 +257,14 @@ func TestInitOIDC(t *testing.T) { clientID string clientSecret string redirectURL string + logoutURL string allowedUsers []string allowedGroups []string wantErr bool }{ { - name: "Fail - Empty configuration", - issuerURL: "", - clientID: "", - clientSecret: "", - redirectURL: "", - allowedUsers: nil, - wantErr: true, + name: "Fail - Empty configuration", + wantErr: true, }, { name: "Success - Valid configuration with users", @@ -288,6 +284,17 @@ func TestInitOIDC(t *testing.T) { allowedGroups: []string{"group1", "group2"}, 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", issuerURL: "https://example.com", @@ -300,7 +307,7 @@ func TestInitOIDC(t *testing.T) { for _, tt := range tests { 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 { t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/internal/api/v1/auth/provider.go b/internal/api/v1/auth/provider.go index 8ea4d320..86e53169 100644 --- a/internal/api/v1/auth/provider.go +++ b/internal/api/v1/auth/provider.go @@ -9,4 +9,5 @@ type Provider interface { CheckToken(r *http.Request) error RedirectLoginPage(w http.ResponseWriter, r *http.Request) LoginCallbackHandler(w http.ResponseWriter, r *http.Request) + LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) } diff --git a/internal/api/v1/auth/userpass.go b/internal/api/v1/auth/userpass.go index 432faff2..7c6512cd 100644 --- a/internal/api/v1/auth/userpass.go +++ b/internal/api/v1/auth/userpass.go @@ -128,6 +128,10 @@ func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Re 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 { if user != auth.username { return ErrInvalidUsername.Subject(user) diff --git a/internal/api/v1/auth/utils.go b/internal/api/v1/auth/utils.go index 1d57de13..05a15585 100644 --- a/internal/api/v1/auth/utils.go +++ b/internal/api/v1/auth/utils.go @@ -62,9 +62,8 @@ func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) { }) } -func LogoutCallbackHandler(auth Provider) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - clearTokenCookie(w, r, auth.TokenCookieName()) - auth.RedirectLoginPage(w, r) - } +// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page.. +func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) { + clearTokenCookie(w, r, auth.TokenCookieName()) + auth.RedirectLoginPage(w, r) } diff --git a/internal/common/env.go b/internal/common/env.go index 4d13afe2..4427816a 100644 --- a/internal/common/env.go +++ b/internal/common/env.go @@ -51,6 +51,7 @@ var ( // OIDC Configuration. OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "") + OIDCLogoutURL = GetEnvString("OIDC_LOGOUT_URL", "") OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "") OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "") OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "") diff --git a/internal/net/http/middleware/oidc.go b/internal/net/http/middleware/oidc.go index 356092d8..3b0adc94 100644 --- a/internal/net/http/middleware/oidc.go +++ b/internal/net/http/middleware/oidc.go @@ -11,9 +11,8 @@ type oidcMiddleware struct { AllowedUsers []string `json:"allowed_users"` AllowedGroups []string `json:"allowed_groups"` - auth auth.Provider - authMux *http.ServeMux - logoutHandler http.HandlerFunc + auth auth.Provider + authMux *http.ServeMux } var OIDC = NewMiddleware[oidcMiddleware]() @@ -41,7 +40,6 @@ func (amw *oidcMiddleware) finalize() error { http.Error(w, "Unauthorized", http.StatusUnauthorized) }) amw.authMux.HandleFunc("/", authProvider.RedirectLoginPage) - amw.logoutHandler = auth.LogoutCallbackHandler(authProvider) amw.auth = authProvider return nil } @@ -52,7 +50,7 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce return false } if r.URL.Path == auth.OIDCLogoutPath { - amw.logoutHandler(w, r) + amw.auth.LogoutCallbackHandler(w, r) return false } return true diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index ed075c8c..cc8d3bb9 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -130,7 +130,6 @@ func TestApplyLabel(t *testing.T) { ExpectEqual(t, b.Container.StopSignal, "SIGTERM") 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.Extra.FileType, "png") ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home") diff --git a/next-release.md b/next-release.md index c3eadb5b..31099401 100644 --- a/next-release.md +++ b/next-release.md @@ -92,6 +92,8 @@ GoDoxy v0.9.0 expected changes - `GODOXY_OIDC_ISSUER_URL` e.g.: - Pocket ID: `https://pocker-id.yourdomain.com` - Authentik: `https://authentik.yourdomain.com/application/o//` **The ending slash is required** + - `GODOXY_OIDC_LOGOUT_URL` _(if your issuer supports it, e.g.)_ + - Authentik: `https://authentik.yourdomain.com/application/o//end-session` - `GODOXY_OIDC_CLIENT_ID` - `GODOXY_OIDC_CLIENT_SECRET` - `GODOXY_OIDC_REDIRECT_URL`