OIDC - support IdP logout (#670)

* OIDC - support IdP logout

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Add support of logout_idp_session parameter

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Fix merge conflict issue

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Restore original package-lock.json

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>

* Cleanup

---------

Signed-off-by: Michael Tupitsyn <michael.tupitsyn@gmail.com>
Co-authored-by: Christoph Haas <christoph.h@sprinternet.at>
This commit is contained in:
Michael Tupitsyn
2026-04-12 04:18:04 -07:00
committed by GitHub
parent 9b437205b1
commit 71806455dd
10 changed files with 120 additions and 21 deletions

View File

@@ -47,6 +47,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true registration_enabled: true
logout_idp_session: true
- id: oidc2 - id: oidc2
provider_name: google2 provider_name: google2
display_name: Login with</br>Google2 display_name: Login with</br>Google2
@@ -57,6 +58,7 @@ auth:
- https://www.googleapis.com/auth/userinfo.email - https://www.googleapis.com/auth/userinfo.email
- https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.profile
registration_enabled: true registration_enabled: true
logout_idp_session: true
oauth: oauth:
- id: google_plain_oauth - id: google_plain_oauth
provider_name: google3 provider_name: google3

View File

@@ -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). - **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. - **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 ### OAuth

View File

@@ -108,12 +108,19 @@ export const authStore = defineStore('auth',{
this.setUserInfo(null) this.setUserInfo(null)
this.ResetReturnUrl() // just to be sure^^ this.ResetReturnUrl() // just to be sure^^
let logoutResponse = null
try { try {
await apiWrapper.post(`/auth/logout`) logoutResponse = await apiWrapper.post(`/auth/logout`)
} catch (e) { } catch (e) {
console.log("Logout request failed:", e) console.log("Logout request failed:", e)
} }
const redirectUrl = logoutResponse?.RedirectUrl
if (redirectUrl) {
window.location.href = redirectUrl
return
}
notify({ notify({
title: "Logged Out", title: "Logged Out",
text: "Logout successful!", text: "Logout successful!",

View File

@@ -25,7 +25,9 @@ type AuthenticationService interface {
// OauthLoginStep1 initiates the OAuth login flow. // OauthLoginStep1 initiates the OAuth login flow.
OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error)
// OauthLoginStep2 completes the OAuth login flow and logins the user in. // 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 { 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 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) oauthCode)
cancel() cancel()
if err != nil { if err != nil {
@@ -346,7 +348,7 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) {
queryParams := returnUrl.Query() 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 // start a fresh session
e.session.DestroyData(r.Context()) e.session.DestroyData(r.Context())
@@ -374,8 +376,9 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User) {
currentSession.OauthState = "" currentSession.OauthState = ""
currentSession.OauthNonce = "" currentSession.OauthNonce = ""
currentSession.OauthProvider = "" currentSession.OauthProvider = oauthProvider
currentSession.OauthReturnTo = "" currentSession.OauthReturnTo = ""
currentSession.OauthIdToken = idTokenHint
e.session.SetData(r.Context(), currentSession) e.session.SetData(r.Context(), currentSession)
} }
@@ -418,7 +421,7 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
return return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
@@ -430,19 +433,33 @@ func (e AuthEndpoint) handleLoginPost() http.HandlerFunc {
// @Tags Authentication // @Tags Authentication
// @Summary Get all available external login providers. // @Summary Get all available external login providers.
// @Produce json // @Produce json
// @Success 200 {object} model.Error // @Success 200 {object} model.LogoutResponse
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc { func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
if !currentSession.LoggedIn { // Not logged in 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 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()) 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 return
} }
e.setAuthenticatedUser(r, user) e.setAuthenticatedUser(r, user, "", "")
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }

View File

@@ -30,6 +30,7 @@ type SessionData struct {
OauthNonce string OauthNonce string
OauthProvider string OauthProvider string
OauthReturnTo string OauthReturnTo string
OauthIdToken string
WebAuthnData string WebAuthnData string
@@ -89,5 +90,6 @@ func (s *SessionWrapper) defaultSessionData() SessionData {
OauthNonce: "", OauthNonce: "",
OauthProvider: "", OauthProvider: "",
OauthReturnTo: "", OauthReturnTo: "",
OauthIdToken: "",
} }
} }

View File

@@ -45,6 +45,11 @@ type OauthInitiationResponse struct {
State string State string
} }
type LogoutResponse struct {
Message string `json:"Message"`
RedirectUrl *string `json:"RedirectUrl,omitempty"`
}
type WebAuthnCredentialRequest struct { type WebAuthnCredentialRequest struct {
Name string `json:"Name"` Name string `json:"Name"`
} }

View File

@@ -68,6 +68,8 @@ type AuthenticatorOauth interface {
// GetAllowedUserGroups returns the list of whitelisted user groups. // GetAllowedUserGroups returns the list of whitelisted user groups.
// If non-empty, at least one user group must match. // If non-empty, at least one user group must match.
GetAllowedUserGroups() []string 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. // 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 // OauthLoginStep2 finishes the oauth authentication flow by exchanging the code for an access token and
// fetching the user information. // 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] oauthProvider, ok := a.oauthAuthenticators[providerId]
if !ok { 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) oauth2Token, err := oauthProvider.Exchange(ctx, code)
if err != nil { 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) rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
if err != nil { 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) userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
if err != nil { 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()) { 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()) { 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, ctx = domain.SetUserInfo(ctx,
@@ -571,7 +574,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: err.Error(), 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() { if user.IsLocked() || user.IsDisabled() {
@@ -583,7 +586,7 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
Error: "user is locked", 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) 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( func (a *Authenticator) processUserInfo(

View File

@@ -79,6 +79,10 @@ func (p PlainOauthAuthenticator) GetAllowedUserGroups() []string {
return p.allowedUserGroups return p.allowedUserGroups
} }
func (p PlainOauthAuthenticator) GetLogoutUrl(_, _ string) (string, bool) {
return "", false
}
// RegistrationEnabled returns whether registration is enabled for the OAuth authenticator. // RegistrationEnabled returns whether registration is enabled for the OAuth authenticator.
func (p PlainOauthAuthenticator) RegistrationEnabled() bool { func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
return p.registrationEnabled return p.registrationEnabled

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/url"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -27,6 +28,8 @@ type OidcAuthenticator struct {
sensitiveInfoLogging bool sensitiveInfoLogging bool
allowedDomains []string allowedDomains []string
allowedUserGroups []string allowedUserGroups []string
endSessionEndpoint string
logoutIdpSession bool
} }
func newOidcAuthenticator( func newOidcAuthenticator(
@@ -63,6 +66,16 @@ func newOidcAuthenticator(
provider.sensitiveInfoLogging = cfg.LogSensitiveInfo provider.sensitiveInfoLogging = cfg.LogSensitiveInfo
provider.allowedDomains = cfg.AllowedDomains provider.allowedDomains = cfg.AllowedDomains
provider.allowedUserGroups = cfg.AllowedUserGroups 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 return provider, nil
} }
@@ -80,6 +93,34 @@ func (o OidcAuthenticator) GetAllowedUserGroups() []string {
return o.allowedUserGroups 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. // RegistrationEnabled returns whether registration is enabled for this authenticator.
func (o OidcAuthenticator) RegistrationEnabled() bool { func (o OidcAuthenticator) RegistrationEnabled() bool {
return o.registrationEnabled return o.registrationEnabled

View File

@@ -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. // 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! // This also includes OAuth tokens! Keep this disabled in production!
LogSensitiveInfo bool `yaml:"log_sensitive_info"` 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. // OAuthProvider contains the configuration for the OAuth provider.