diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e2576760f..c2a682eac 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -533,9 +533,13 @@ export const useAuthStore = defineStore('auth', () => { // Revoke the server session so the refresh token can't be reused. // Best-effort: if the network call fails, still clean up locally. + // The server builds the OIDC RP-Initiated Logout URL (with id_token_hint, + // post_logout_redirect_uri and client_id) and returns it here. + let oidcLogoutUrl = '' try { const HTTP = AuthenticatedHTTPFactory() - await HTTP.post('user/logout') + const {data} = await HTTP.post('user/logout') + oidcLogoutUrl = data?.oidc_logout_url ?? '' } catch (_e) { // Ignore — session will expire naturally } @@ -547,7 +551,14 @@ export const useAuthStore = defineStore('auth', () => { await router.push({name: 'user.login'}) await checkAuth() - // if configured, redirect to OIDC Provider on logout + // Redirect to the OIDC provider's end-session endpoint so the provider + // session is ended too. Prefer the server-built URL (RP-Initiated Logout + // with id_token_hint), falling back to the static logout url if the + // server did not return one. + if (oidcLogoutUrl) { + window.location.href = oidcLogoutUrl + return + } const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia) if (fullProvider) { redirectToProviderOnLogout(fullProvider) diff --git a/pkg/migration/20260619155410.go b/pkg/migration/20260619155410.go new file mode 100644 index 000000000..0575900c1 --- /dev/null +++ b/pkg/migration/20260619155410.go @@ -0,0 +1,57 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migration + +import ( + "time" + + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +// Mirrors models.Session; the two new columns store what is needed to build an +// RP-Initiated Logout request (id_token_hint + which provider to log out from) +// when the session was created via OpenID Connect. +type sessionOIDCLogout20260619155410 struct { + ID string `xorm:"varchar(36) not null unique pk"` + UserID int64 `xorm:"bigint not null index"` + TokenHash string `xorm:"varchar(64) not null unique index"` + DeviceInfo string `xorm:"text"` + IPAddress string `xorm:"varchar(100)"` + IsLongSession bool `xorm:"not null default false"` + OIDCIDToken string `xorm:"text"` + OIDCProviderKey string `xorm:"varchar(250)"` + LastActive time.Time `xorm:"not null"` + Created time.Time `xorm:"created not null"` +} + +func (sessionOIDCLogout20260619155410) TableName() string { + return "sessions" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260619155410", + Description: "Add oidc_id_token and oidc_provider_key columns to sessions for RP-Initiated Logout", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync(sessionOIDCLogout20260619155410{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/sessions.go b/pkg/models/sessions.go index 9c7a5d1f1..b2361ec67 100644 --- a/pkg/models/sessions.go +++ b/pkg/models/sessions.go @@ -49,6 +49,13 @@ type Session struct { IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."` // Whether this is a "remember me" session (controls max refresh lifetime). IsLongSession bool `xorm:"not null default false" json:"-"` + // The raw OpenID Connect ID token, kept only for sessions created via OIDC so + // it can be replayed as the id_token_hint in an RP-Initiated Logout request. + // Never exposed over the API. Empty for non-OIDC sessions. + OIDCIDToken string `xorm:"text" json:"-"` + // The key of the OIDC provider that created this session, used to look up its + // end-session endpoint at logout. Empty for non-OIDC sessions. + OIDCProviderKey string `xorm:"varchar(250)" json:"-"` // When this session was last refreshed. LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."` // When this session was created (login time). @@ -81,9 +88,19 @@ func generateHashedToken() (rawToken, hash string, err error) { return rawToken, HashSessionToken(rawToken), nil } +// SessionOIDCData carries the OpenID Connect metadata persisted on a session so +// an RP-Initiated Logout request can be built later. It is nil for non-OIDC +// logins. +type SessionOIDCData struct { + IDToken string + ProviderKey string +} + // CreateSession creates a new session record and generates a refresh token. // Returns the session with RefreshToken populated (cleartext, shown only once). -func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) { +// oidc is optional: pass it for OpenID Connect logins so the raw id_token and +// provider key are stored for RP-Initiated Logout; pass nil otherwise. +func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool, oidc *SessionOIDCData) (*Session, error) { rawToken, hash, err := generateHashedToken() if err != nil { return nil, err @@ -98,6 +115,10 @@ func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, IsLongSession: isLongSession, LastActive: time.Now(), } + if oidc != nil { + session.OIDCIDToken = oidc.IDToken + session.OIDCProviderKey = oidc.ProviderKey + } _, err = s.Insert(session) if err != nil { diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index f94537158..59895beaf 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -112,12 +112,14 @@ type IssuedUserToken struct { // IssueUserToken creates a session for the user and mints a JWT access token plus // a refresh token for it. It is the transport-agnostic core both v1 (which writes // the echo response) and v2 (Huma) call; callers set the refresh cookie and the -// Cache-Control header themselves via WriteUserAuthCookies. -func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool) (*IssuedUserToken, error) { +// Cache-Control header themselves via WriteUserAuthCookies. oidc is optional: +// pass it for OpenID Connect logins so the id_token is stored for RP-Initiated +// Logout, nil otherwise. +func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool, oidc *models.SessionOIDCData) (*IssuedUserToken, error) { s := db.NewSession() defer s.Close() - session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long) + session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long, oidc) if err != nil { _ = s.Rollback() return nil, err @@ -161,8 +163,9 @@ func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) { } // NewUserAuthTokenResponse creates a new user auth token response from a user object. -func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { - token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long) +// oidc is optional OpenID Connect session metadata for RP-Initiated Logout; pass nil for local/LDAP logins. +func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool, oidc *models.SessionOIDCData) error { + token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long, oidc) if err != nil { return err } diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 11f85772e..97978c0bf 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -114,7 +114,7 @@ func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInf } // Create a session (reuses existing session infrastructure) - session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) + session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false, nil) if err != nil { _ = s.Rollback() return nil, err diff --git a/pkg/modules/auth/openid/logout.go b/pkg/modules/auth/openid/logout.go new file mode 100644 index 000000000..5a8088b58 --- /dev/null +++ b/pkg/modules/auth/openid/logout.go @@ -0,0 +1,115 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package openid + +import ( + "net/url" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" +) + +// EndSessionEndpoint returns the provider's RP-Initiated Logout endpoint as +// published in its OpenID Connect discovery document (the REQUIRED +// `end_session_endpoint` metadata, RP-Initiated Logout 1.0 §2.1). When the +// provider does not publish one, it falls back to the statically configured +// `logouturl` so existing setups keep working. +func (p *Provider) EndSessionEndpoint() string { + if p.openIDProvider == nil { + if err := p.setOicdProvider(); err != nil { + return p.LogoutURL + } + } + + var meta struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := p.openIDProvider.Claims(&meta); err != nil { + log.Debugf("Could not read end_session_endpoint for provider %s: %v", p.Key, err) + return p.LogoutURL + } + if meta.EndSessionEndpoint == "" { + return p.LogoutURL + } + return meta.EndSessionEndpoint +} + +// BuildEndSessionURL constructs an OpenID Connect RP-Initiated Logout 1.0 request +// URL for the given provider key and stored session OIDC data. +// +// Per RP-Initiated Logout 1.0 §2 it appends: +// - id_token_hint: the ID token previously issued to this session. RECOMMENDED; +// it lets the OP skip the logout-confirmation prompt and is what makes the OP +// honor post_logout_redirect_uri (the OP MAY require it, §3). +// - post_logout_redirect_uri: where the OP redirects the user agent after +// logout. MUST be pre-registered with the OP. Defaults to service.publicurl +// (the Vikunja frontend) so the user lands back on Vikunja's login page. +// - client_id: the RP's client identifier (§2). Always sent; the OP verifies it +// matches the one in id_token_hint. +// +// It returns "" (and the caller skips the redirect) when neither an +// end_session_endpoint nor a static logouturl is configured. +func BuildEndSessionURL(providerKey string, oidc *models.SessionOIDCData) (string, error) { + provider, err := GetProvider(providerKey) + if err != nil { + return "", err + } + if provider == nil { + return "", nil + } + + idToken := "" + if oidc != nil { + idToken = oidc.IDToken + } + + return buildEndSessionURL( + provider.EndSessionEndpoint(), + provider.ClientID, + idToken, + config.ServicePublicURL.GetString(), + ) +} + +// buildEndSessionURL assembles the RP-Initiated Logout query string onto the +// given end-session endpoint. Empty optional params are omitted. Returns "" when +// no endpoint is configured. +func buildEndSessionURL(endpoint, clientID, idToken, postLogoutRedirectURI string) (string, error) { + if endpoint == "" { + return "", nil + } + + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + q := u.Query() + if clientID != "" { + q.Set("client_id", clientID) + } + if idToken != "" { + q.Set("id_token_hint", idToken) + } + if postLogoutRedirectURI != "" { + q.Set("post_logout_redirect_uri", postLogoutRedirectURI) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} diff --git a/pkg/modules/auth/openid/logout_test.go b/pkg/modules/auth/openid/logout_test.go new file mode 100644 index 000000000..132bd8996 --- /dev/null +++ b/pkg/modules/auth/openid/logout_test.go @@ -0,0 +1,160 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package openid + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/keyvalue" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newMockOIDCServerWithEndSession serves a discovery document that includes an +// end_session_endpoint, exercising the RP-Initiated Logout discovery path. +func newMockOIDCServerWithEndSession() *httptest.Server { + var server *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + discovery := map[string]interface{}{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/auth", + "token_endpoint": server.URL + "/token", + "jwks_uri": server.URL + "/jwks", + "end_session_endpoint": server.URL + "/logout", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(discovery) + }) + server = httptest.NewServer(mux) + return server +} + +func TestBuildEndSessionURLAssembly(t *testing.T) { + t.Run("all params", func(t *testing.T) { + got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "the-id-token", "https://vikunja.example.com/") + require.NoError(t, err) + + u, err := url.Parse(got) + require.NoError(t, err) + q := u.Query() + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "op.example.com", u.Host) + assert.Equal(t, "/logout", u.Path) + assert.Equal(t, "the-id-token", q.Get("id_token_hint")) + assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri")) + assert.Equal(t, "my-client", q.Get("client_id")) + }) + + t.Run("preserves existing endpoint query params", func(t *testing.T) { + got, err := buildEndSessionURL("https://op.example.com/logout?foo=bar", "my-client", "the-id-token", "https://vikunja.example.com/") + require.NoError(t, err) + + u, err := url.Parse(got) + require.NoError(t, err) + q := u.Query() + assert.Equal(t, "bar", q.Get("foo")) + assert.Equal(t, "the-id-token", q.Get("id_token_hint")) + }) + + t.Run("omits id_token_hint when no token", func(t *testing.T) { + got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "", "https://vikunja.example.com/") + require.NoError(t, err) + + u, err := url.Parse(got) + require.NoError(t, err) + q := u.Query() + assert.False(t, q.Has("id_token_hint")) + assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri")) + assert.Equal(t, "my-client", q.Get("client_id")) + }) + + t.Run("empty endpoint returns empty", func(t *testing.T) { + got, err := buildEndSessionURL("", "my-client", "the-id-token", "https://vikunja.example.com/") + require.NoError(t, err) + assert.Empty(t, got) + }) +} + +func TestBuildEndSessionURLFromDiscovery(t *testing.T) { + defer CleanupSavedOpenIDProviders() + + server := newMockOIDCServerWithEndSession() + defer server.Close() + + config.AuthOpenIDEnabled.Set(true) + config.ServicePublicURL.Set("https://vikunja.example.com/") + config.AuthOpenIDProviders.Set(map[string]interface{}{ + "provider1": map[string]interface{}{ + "name": "Provider One", + "authurl": server.URL, + "clientid": "client1", + "clientsecret": "secret1", + }, + }) + _ = keyvalue.Del("openid_providers") + _ = keyvalue.Del("openid_provider_provider1") + + got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{ + IDToken: "raw-id-token", + ProviderKey: "provider1", + }) + require.NoError(t, err) + + u, err := url.Parse(got) + require.NoError(t, err) + q := u.Query() + assert.Equal(t, server.URL+"/logout", u.Scheme+"://"+u.Host+u.Path) + assert.Equal(t, "raw-id-token", q.Get("id_token_hint")) + assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri")) + assert.Equal(t, "client1", q.Get("client_id")) +} + +func TestEndSessionEndpointFallsBackToStaticLogoutURL(t *testing.T) { + defer CleanupSavedOpenIDProviders() + + // This mock server publishes no end_session_endpoint, so the provider must + // fall back to the statically configured logouturl. + server := newMockOIDCServer() + defer server.Close() + + config.AuthOpenIDEnabled.Set(true) + config.AuthOpenIDProviders.Set(map[string]interface{}{ + "provider1": map[string]interface{}{ + "name": "Provider One", + "authurl": server.URL, + "clientid": "client1", + "clientsecret": "secret1", + "logouturl": "https://op.example.com/static-logout", + }, + }) + _ = keyvalue.Del("openid_providers") + _ = keyvalue.Del("openid_provider_provider1") + + provider, err := GetProvider("provider1") + require.NoError(t, err) + require.NotNil(t, provider) + + assert.Equal(t, "https://op.example.com/static-logout", provider.EndSessionEndpoint()) +} diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 47ade7dc9..ac5ba5780 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -173,7 +173,7 @@ func HandleCallback(c *echo.Context) error { return &models.ErrOpenIDBadRequest{Message: "Bad data"} } - u, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider")) + u, oidcData, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider")) if err != nil { var detailedErr *models.ErrOpenIDBadRequestWithDetails if errors.As(err, &detailedErr) { @@ -186,7 +186,7 @@ func HandleCallback(c *echo.Context) error { } // Create token - return auth.NewUserAuthTokenResponse(u, c, false) + return auth.NewUserAuthTokenResponse(u, c, false, oidcData) } // AuthenticateCallback resolves an OpenID Connect callback to an authenticated @@ -196,18 +196,25 @@ func HandleCallback(c *echo.Context) error { // handler and the v2 Huma handler; the caller issues the auth token. The // ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render // its bespoke body and v2 can map it to RFC 9457. -func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, error) { +func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, *models.SessionOIDCData, error) { // ctx is threaded through only to dispatch the login event; the OIDC token // exchange, claim verification and user/avatar sync run on their own // background contexts, exactly as the v1 callback always did. - provider, oauthToken, idToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck + provider, oauthToken, idToken, rawIDToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck if err != nil { - return nil, err + return nil, nil, err + } + + // Stored so logout can replay it as id_token_hint in an RP-Initiated Logout + // request. See pkg/modules/auth/openid/logout.go. + oidcData := &models.SessionOIDCData{ + IDToken: rawIDToken, + ProviderKey: providerKey, } cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck if err != nil { - return nil, err + return nil, nil, err } s := db.NewSession() @@ -221,16 +228,16 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) if err != nil { _ = s.Rollback() log.Errorf("Error creating new user for provider %s: %v", provider.Name, err) - return nil, err + return nil, nil, err } if u.Status == user.StatusDisabled { _ = s.Rollback() - return nil, &user.ErrAccountDisabled{UserID: u.ID} + return nil, nil, &user.ErrAccountDisabled{UserID: u.ID} } if u.Status == user.StatusAccountLocked { _ = s.Rollback() - return nil, &user.ErrAccountLocked{UserID: u.ID} + return nil, nil, &user.ErrAccountLocked{UserID: u.ID} } // Must run before team sync so a failed 2FA attempt cannot mutate team @@ -247,26 +254,26 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) if user.IsErrInvalidTOTPPasscode(err) { user.HandleFailedTOTPAuth(u) } - return nil, err + return nil, nil, err } teamData := getTeamDataFromToken(cl.VikunjaGroups, provider) err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name) if err != nil { - return nil, err + return nil, nil, err } err = s.Commit() if err != nil { _ = s.Rollback() log.Errorf("Error creating new team for provider %s: %v", provider.Name, err) - return nil, err + return nil, nil, err } events.DispatchPending(ctx, s) - return u, nil + return u, oidcData, nil } func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) { @@ -543,13 +550,13 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo // and verifies the returned ID token. It takes an already-bound Callback so it // can be shared by the v1 echo handler (which binds from the request) and the v2 // Huma handler (which binds via its typed body). -func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, error) { +func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, string, error) { provider, err := GetProvider(providerKey) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, "", err } if provider == nil { - return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"} + return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Provider does not exist"} } log.Debugf("Trying to authenticate user using provider: %s", provider.Key) @@ -565,25 +572,25 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To if err := json.Unmarshal(rerr.Body, &details); err != nil { log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err) log.Debugf("Raw token value is %s", rerr.Body) - return nil, nil, nil, err + return nil, nil, nil, "", err } log.Errorf("Error retrieving token: %s", err) log.Debugf("Raw token value is %s", rerr.Body) - return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{ + return nil, nil, nil, "", &models.ErrOpenIDBadRequestWithDetails{ Message: "Could not authenticate against third party.", Details: details, } } - return nil, nil, nil, err + return nil, nil, nil, "", err } // Extract the ID Token from OAuth2 token. rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { log.Debugf("Could not get id_token, raw token is %v", oauth2Token) - return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"} + return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Missing token"} } verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID}) @@ -592,8 +599,8 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To idToken, err := verifier.Verify(context.Background(), rawIDToken) if err != nil { log.Errorf("Error verifying token for provider %s: %v", provider.Name, err) - return nil, nil, nil, err + return nil, nil, nil, "", err } - return provider, oauth2Token, idToken, nil + return provider, oauth2Token, idToken, rawIDToken, nil } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index 153d851e8..d300fd956 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -27,6 +27,7 @@ import ( "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth/ldap" + "code.vikunja.io/api/pkg/modules/auth/openid" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/user" @@ -192,24 +193,57 @@ func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error { // API token or a link share), matching v1. Shared by v1 and v2; the caller is // responsible for clearing the refresh cookie. func DeleteSession(sid string) error { + _, err := LogoutSession(sid) + return err +} + +// LogoutSession reads the session, builds an OpenID Connect RP-Initiated Logout +// URL when the session was created via OIDC, then deletes the session. It +// returns the end-session URL (empty for non-OIDC sessions or when no logout +// endpoint is configured) so the frontend can redirect the user agent to the +// identity provider's end_session_endpoint with id_token_hint and +// post_logout_redirect_uri. An empty sid is a no-op. The caller clears the +// refresh cookie. +func LogoutSession(sid string) (endSessionURL string, err error) { if sid == "" { - return nil + return "", nil } s := db.NewSession() defer s.Close() + // Read the session before deleting so the stored id_token can be replayed as + // id_token_hint. A missing session just means there is nothing to log out. + session, err := models.GetSessionByID(s, sid) + if err != nil && !models.IsErrSessionNotFound(err) { + _ = s.Rollback() + return "", err + } + if session != nil && session.OIDCProviderKey != "" { + url, buildErr := openid.BuildEndSessionURL(session.OIDCProviderKey, &models.SessionOIDCData{ + IDToken: session.OIDCIDToken, + ProviderKey: session.OIDCProviderKey, + }) + if buildErr != nil { + // Don't fail logout just because the logout URL could not be built; + // the session is still destroyed server-side below. + log.Errorf("Could not build OIDC end-session URL for session %s: %s", sid, buildErr) + } else { + endSessionURL = url + } + } + if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil { _ = s.Rollback() - return err + return "", err } if err := s.Commit(); err != nil { _ = s.Rollback() - return err + return "", err } - return nil + return endSessionURL, nil } // ResetPassword resets a user's password from a previously issued reset token diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 2d740ffdf..a1974da58 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -56,7 +56,7 @@ func Login(c *echo.Context) (err error) { } // Create token - return auth.NewUserAuthTokenResponse(user, c, u.LongToken) + return auth.NewUserAuthTokenResponse(user, c, u.LongToken, nil) } // RenewToken renews a link share token only. User tokens must use @@ -150,12 +150,23 @@ func RefreshToken(c *echo.Context) (err error) { return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken}) } +// LogoutResponse confirms a successful logout and, for sessions created via +// OpenID Connect, carries the provider's RP-Initiated Logout URL the frontend +// should redirect the user agent to so the OP session is ended too. +type LogoutResponse struct { + Message string `json:"message"` + // OIDCLogoutURL is the fully-built end_session_endpoint URL (with + // id_token_hint, post_logout_redirect_uri and client_id). Empty for non-OIDC + // sessions. + OIDCLogoutURL string `json:"oidc_logout_url,omitempty"` +} + // Logout deletes the current session from the server. // @Summary Logout -// @Description Destroys the current session and clears the refresh token cookie. +// @Description Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too. // @tags auth // @Produce json -// @Success 200 {object} models.Message "Successfully logged out." +// @Success 200 {object} v1.LogoutResponse "Successfully logged out." // @Router /user/logout [post] func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) @@ -177,7 +188,8 @@ func Logout(c *echo.Context) (err error) { } } - if err := shared.DeleteSession(sid); err != nil { + oidcLogoutURL, err := shared.LogoutSession(sid) + if err != nil { return err } @@ -187,5 +199,8 @@ func Logout(c *echo.Context) (err error) { } } - return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) + return c.JSON(http.StatusOK, LogoutResponse{ + Message: "Successfully logged out.", + OIDCLogoutURL: oidcLogoutURL, + }) } diff --git a/pkg/routes/api/v2/auth_login.go b/pkg/routes/api/v2/auth_login.go index d6ff0ff19..431b44e11 100644 --- a/pkg/routes/api/v2/auth_login.go +++ b/pkg/routes/api/v2/auth_login.go @@ -46,6 +46,11 @@ type authTokenBody struct { type logoutBody struct { Body struct { Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + // OIDCLogoutURL is the provider's RP-Initiated Logout URL (with + // id_token_hint, post_logout_redirect_uri and client_id) for sessions + // created via OpenID Connect. The client should redirect the user agent + // to it to end the provider session too. Empty for non-OIDC sessions. + OIDCLogoutURL string `json:"oidc_logout_url,omitempty" readOnly:"true" doc:"RP-Initiated Logout URL to redirect to for OpenID Connect sessions; empty otherwise."` } } @@ -86,7 +91,7 @@ func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBo } deviceInfo, ipAddress := requestClientInfo(ctx) - token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken) + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken, nil) if err != nil { return nil, translateDomainError(err) } @@ -107,12 +112,14 @@ func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) { sid = auth.SessionIDFromContext(ec) } - if err := shared.DeleteSession(sid); err != nil { + oidcLogoutURL, err := shared.LogoutSession(sid) //nolint:contextcheck // OIDC provider discovery resolves from a cached, context-less map and runs on its own background context, like the OIDC callback. + if err != nil { return nil, translateDomainError(err) } out := &logoutBody{} out.Body.Message = "Successfully logged out." + out.Body.OIDCLogoutURL = oidcLogoutURL return out, nil } diff --git a/pkg/routes/api/v2/auth_openid.go b/pkg/routes/api/v2/auth_openid.go index b52d7dca1..5e029a184 100644 --- a/pkg/routes/api/v2/auth_openid.go +++ b/pkg/routes/api/v2/auth_openid.go @@ -55,14 +55,14 @@ func authOpenIDCallback(ctx context.Context, in *struct { Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."` Body openid.Callback `doc:"The provider callback, carrying the authorization code."` }) (*authTokenBody, error) { - u, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback. + u, oidcData, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback. if err != nil { return nil, translateOpenIDError(err) } deviceInfo, ipAddress := requestClientInfo(ctx) // OIDC logins are not "remember me" sessions; v1 always issues a short one. - token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false) + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false, oidcData) if err != nil { return nil, translateDomainError(err) } diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go index ad83fc811..48931effa 100644 --- a/pkg/webtests/huma_auth_login_test.go +++ b/pkg/webtests/huma_auth_login_test.go @@ -130,7 +130,7 @@ func TestHumaLogout(t *testing.T) { // Create a session so logout has something to delete, then mint a JWT whose // sid claim points at it. s := db.NewSession() - session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false) + session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil) require.NoError(t, err) require.NoError(t, s.Commit()) require.NoError(t, s.Close())