From 71806455dd5b024d08256df0fb1a34b60d3303c3 Mon Sep 17 00:00:00 2001 From: Michael Tupitsyn <320082+mtupitsyn@users.noreply.github.com> Date: Sun, 12 Apr 2026 04:18:04 -0700 Subject: [PATCH] OIDC - support IdP logout (#670) * OIDC - support IdP logout Signed-off-by: Michael Tupitsyn * Add support of logout_idp_session parameter Signed-off-by: Michael Tupitsyn * Fix merge conflict issue Signed-off-by: Michael Tupitsyn * Restore original package-lock.json Signed-off-by: Michael Tupitsyn * Cleanup --------- Signed-off-by: Michael Tupitsyn Co-authored-by: Christoph Haas --- config.yml.sample | 2 + docs/documentation/configuration/overview.md | 4 ++ frontend/src/stores/auth.js | 9 +++- .../v0/handlers/endpoint_authentication.go | 37 ++++++++++++----- internal/app/api/v0/handlers/web_session.go | 2 + .../app/api/v0/model/models_authentication.go | 5 +++ internal/app/auth/auth.go | 32 ++++++++++----- internal/app/auth/auth_oauth.go | 4 ++ internal/app/auth/auth_oidc.go | 41 +++++++++++++++++++ internal/config/auth.go | 5 +++ 10 files changed, 120 insertions(+), 21 deletions(-) diff --git a/config.yml.sample b/config.yml.sample index 5d4c594..51a9680 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -47,6 +47,7 @@ auth: - https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.profile registration_enabled: true + logout_idp_session: true - id: oidc2 provider_name: google2 display_name: Login with
Google2 @@ -57,6 +58,7 @@ auth: - https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.profile registration_enabled: true + logout_idp_session: true oauth: - id: google_plain_oauth provider_name: google3 diff --git a/docs/documentation/configuration/overview.md b/docs/documentation/configuration/overview.md index 8581082..0b96c38 100644 --- a/docs/documentation/configuration/overview.md +++ b/docs/documentation/configuration/overview.md @@ -600,6 +600,10 @@ Below are the properties for each OIDC provider entry inside `auth.oidc`: - **Description:** If `true`, sensitive OIDC user data, such as tokens and raw responses, will be logged at the trace level upon login (for debugging). - **Important:** Keep this setting disabled in production environments! Remove logs once you finished debugging authentication issues. +#### `logout_idp_session` +- **Default:** `true` +- **Description:** If `true` (default), WireGuard Portal will redirect the user to the OIDC provider's `end_session_endpoint` after local logout, terminating the session at the IdP as well. Set to `false` to only invalidate the local WireGuard Portal session without touching the IdP session. + --- ### OAuth diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 83d97d2..b72538f 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -108,12 +108,19 @@ export const authStore = defineStore('auth',{ this.setUserInfo(null) this.ResetReturnUrl() // just to be sure^^ + let logoutResponse = null try { - await apiWrapper.post(`/auth/logout`) + logoutResponse = await apiWrapper.post(`/auth/logout`) } catch (e) { console.log("Logout request failed:", e) } + const redirectUrl = logoutResponse?.RedirectUrl + if (redirectUrl) { + window.location.href = redirectUrl + return + } + notify({ title: "Logged Out", text: "Logout successful!", diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index b14648d..2e019e4 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -25,7 +25,9 @@ type AuthenticationService interface { // OauthLoginStep1 initiates the OAuth login flow. OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) // OauthLoginStep2 completes the OAuth login flow and logins the user in. - OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) + OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) + // OauthProviderLogoutUrl returns an IdP logout URL for the given provider if supported. + OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) } type WebAuthnService interface { @@ -331,7 +333,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { } loginCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // avoid long waits - user, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, + user, idTokenHint, err := e.authService.OauthLoginStep2(loginCtx, provider, currentSession.OauthNonce, oauthCode) cancel() if err != nil { @@ -346,7 +348,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { return } - e.setAuthenticatedUser(r, user) + e.setAuthenticatedUser(r, user, provider, idTokenHint) if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { queryParams := returnUrl.Query() @@ -359,7 +361,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc { } } -func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) { +func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, oauthProvider, idTokenHint string) { // start a fresh session e.session.DestroyData(r.Context()) @@ -374,8 +376,9 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) { currentSession.OauthState = "" currentSession.OauthNonce = "" - currentSession.OauthProvider = "" + currentSession.OauthProvider = oauthProvider currentSession.OauthReturnTo = "" + currentSession.OauthIdToken = idTokenHint e.session.SetData(r.Context(), currentSession) } @@ -418,7 +421,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc { return } - e.setAuthenticatedUser(r, user) + e.setAuthenticatedUser(r, user, "", "") respond.JSON(w, http.StatusOK, user) } @@ -430,19 +433,33 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc { // @Tags Authentication // @Summary Get all available external login providers. // @Produce json -// @Success 200 {object} model.Error +// @Success 200 {object} model.LogoutResponse // @Router /auth/logout [post] func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { currentSession := e.session.GetData(r.Context()) if !currentSession.LoggedIn { // Not logged in - respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "not logged in"}) + respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "not logged in"}) return } + postLogoutRedirectUri := e.cfg.Web.ExternalUrl + if e.cfg.Web.BasePath != "" { + postLogoutRedirectUri += e.cfg.Web.BasePath + } + postLogoutRedirectUri += "/#/login" + + var redirectUrl *string + if currentSession.OauthProvider != "" { + if idpLogoutUrl, ok := e.authService.OauthProviderLogoutUrl(currentSession.OauthProvider, + currentSession.OauthIdToken, postLogoutRedirectUri); ok { + redirectUrl = &idpLogoutUrl + } + } + e.session.DestroyData(r.Context()) - respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "logout ok"}) + respond.JSON(w, http.StatusOK, model.LogoutResponse{Message: "logout ok", RedirectUrl: redirectUrl}) } } @@ -693,7 +710,7 @@ func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc { return } - e.setAuthenticatedUser(r, user) + e.setAuthenticatedUser(r, user, "", "") respond.JSON(w, http.StatusOK, model.NewUser(user, false)) } diff --git a/internal/app/api/v0/handlers/web_session.go b/internal/app/api/v0/handlers/web_session.go index fcb6eca..5b3ba26 100644 --- a/internal/app/api/v0/handlers/web_session.go +++ b/internal/app/api/v0/handlers/web_session.go @@ -30,6 +30,7 @@ type SessionData struct { OauthNonce string OauthProvider string OauthReturnTo string + OauthIdToken string WebAuthnData string @@ -89,5 +90,6 @@ func (s *SessionWrapper) defaultSessionData() SessionData { OauthNonce: "", OauthProvider: "", OauthReturnTo: "", + OauthIdToken: "", } } diff --git a/internal/app/api/v0/model/models_authentication.go b/internal/app/api/v0/model/models_authentication.go index b7283b1..5bc2b4e 100644 --- a/internal/app/api/v0/model/models_authentication.go +++ b/internal/app/api/v0/model/models_authentication.go @@ -45,6 +45,11 @@ type OauthInitiationResponse struct { State string } +type LogoutResponse struct { + Message string `json:"Message"` + RedirectUrl *string `json:"RedirectUrl,omitempty"` +} + type WebAuthnCredentialRequest struct { Name string `json:"Name"` } diff --git a/internal/app/auth/auth.go b/internal/app/auth/auth.go index bd2051c..c4ce8b3 100644 --- a/internal/app/auth/auth.go +++ b/internal/app/auth/auth.go @@ -68,6 +68,8 @@ type AuthenticatorOauth interface { // GetAllowedUserGroups returns the list of whitelisted user groups. // If non-empty, at least one user group must match. GetAllowedUserGroups() []string + // GetLogoutUrl returns an IdP logout URL if supported by the provider. + GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) } // AuthenticatorLdap is the interface for all LDAP authenticators. @@ -529,33 +531,34 @@ func isAnyAllowedUserGroup(userGroups, allowedUserGroups []string) bool { // OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and // fetching the user information. -func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) { +func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, string, error) { oauthProvider, ok := a.oauthAuthenticators[providerId] if !ok { - return nil, fmt.Errorf("missing oauth provider %s", providerId) + return nil, "", fmt.Errorf("missing oauth provider %s", providerId) } oauth2Token, err := oauthProvider.Exchange(ctx, code) if err != nil { - return nil, fmt.Errorf("unable to exchange code: %w", err) + return nil, "", fmt.Errorf("unable to exchange code: %w", err) } + idTokenHint, _ := oauth2Token.Extra("id_token").(string) rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce) if err != nil { - return nil, fmt.Errorf("unable to fetch user information: %w", err) + return nil, "", fmt.Errorf("unable to fetch user information: %w", err) } userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo) if err != nil { - return nil, fmt.Errorf("unable to parse user information: %w", err) + return nil, "", fmt.Errorf("unable to parse user information: %w", err) } if !isDomainAllowed(userInfo.Email, oauthProvider.GetAllowedDomains()) { - return nil, fmt.Errorf("user %s is not in allowed domains", userInfo.Email) + return nil, "", fmt.Errorf("user %s is not in allowed domains", userInfo.Email) } if !isAnyAllowedUserGroup(userInfo.UserGroups, oauthProvider.GetAllowedUserGroups()) { - return nil, fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier) + return nil, "", fmt.Errorf("user %s is not in allowed user groups", userInfo.Identifier) } ctx = domain.SetUserInfo(ctx, @@ -571,7 +574,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, Error: err.Error(), }, }) - return nil, fmt.Errorf("unable to process user information: %w", err) + return nil, "", fmt.Errorf("unable to process user information: %w", err) } if user.IsLocked() || user.IsDisabled() { @@ -583,7 +586,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, Error: "user is locked", }, }) - return nil, errors.New("user is locked") + return nil, "", errors.New("user is locked") } a.bus.Publish(app.TopicAuthLogin, user.Identifier) @@ -595,7 +598,16 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, }, }) - return user, nil + return user, idTokenHint, nil +} + +func (a *Authenticator) OauthProviderLogoutUrl(providerId, idTokenHint, postLogoutRedirectUri string) (string, bool) { + oauthProvider, ok := a.oauthAuthenticators[providerId] + if !ok { + return "", false + } + + return oauthProvider.GetLogoutUrl(idTokenHint, postLogoutRedirectUri) } func (a *Authenticator) processUserInfo( diff --git a/internal/app/auth/auth_oauth.go b/internal/app/auth/auth_oauth.go index 87a7573..37f6e39 100644 --- a/internal/app/auth/auth_oauth.go +++ b/internal/app/auth/auth_oauth.go @@ -79,6 +79,10 @@ func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string { return p.allowedUserGroups } +func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) { + return "", false +} + // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. func (p PlainOauthAuthenticator) RegistrationEnabled() bool { return p.registrationEnabled diff --git a/internal/app/auth/auth_oidc.go b/internal/app/auth/auth_oidc.go index 4d774bd..ec53fdf 100644 --- a/internal/app/auth/auth_oidc.go +++ b/internal/app/auth/auth_oidc.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" @@ -27,6 +28,8 @@ type OidcAuthenticator struct { sensitiveInfoLogging bool allowedDomains []string allowedUserGroups []string + endSessionEndpoint string + logoutIdpSession bool } func newOidcAuthenticator( @@ -63,6 +66,16 @@ func newOidcAuthenticator( provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.allowedDomains = cfg.AllowedDomains provider.allowedUserGroups = cfg.AllowedUserGroups + provider.logoutIdpSession = cfg.LogoutIdpSession == nil || *cfg.LogoutIdpSession + + var providerMetadata struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err = provider.provider.Claims(&providerMetadata); err != nil { + slog.Debug("OIDC: failed to parse provider metadata", "provider", cfg.ProviderName, "error", err) + } else { + provider.endSessionEndpoint = providerMetadata.EndSessionEndpoint + } return provider, nil } @@ -80,6 +93,34 @@ func (o OidcAuthenticator) GetAllowedUserGroups() []string { return o.allowedUserGroups } +func (o OidcAuthenticator) GetLogoutUrl(idTokenHint, postLogoutRedirectUri string) (string, bool) { + if !o.logoutIdpSession { + return "", false + } + if o.endSessionEndpoint == "" { + slog.Debug("OIDC logout URL generation disabled: provider has no end_session_endpoint", "provider", o.name) + return "", false + } + + logoutUrl, err := url.Parse(o.endSessionEndpoint) + if err != nil { + slog.Debug("OIDC logout URL generation failed, unable to parse end_session_endpoint url", + "provider", o.name, "error", err) + return "", false + } + + params := logoutUrl.Query() + if idTokenHint != "" { + params.Set("id_token_hint", idTokenHint) + } + if postLogoutRedirectUri != "" { + params.Set("post_logout_redirect_uri", postLogoutRedirectUri) + } + logoutUrl.RawQuery = params.Encode() + + return logoutUrl.String(), true +} + // RegistrationEnabled returns whether registration is enabled for this authenticator. func (o OidcAuthenticator) RegistrationEnabled() bool { return o.registrationEnabled diff --git a/internal/config/auth.go b/internal/config/auth.go index c91598e..bd2bc34 100644 --- a/internal/config/auth.go +++ b/internal/config/auth.go @@ -278,6 +278,11 @@ type OpenIDConnectProvider struct { // If LogSensitiveInfo is set to true, sensitive information retrieved from the OIDC provider will be logged in trace level. // This also includes OAuth tokens! Keep this disabled in production! LogSensitiveInfo bool `yaml:"log_sensitive_info"` + + // LogoutIdpSession controls whether the user's session at the OIDC provider is terminated on logout. + // If set to true (default), the user will be redirected to the IdP's end_session_endpoint after local logout. + // If set to false, only the local wg-portal session is invalidated. + LogoutIdpSession *bool `yaml:"logout_idp_session"` } // OAuthProvider contains the configuration for the OAuth provider.