diff --git a/connector/connector.go b/connector/connector.go index 1e554789..9ab19cb9 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -116,3 +116,23 @@ type RefreshConnector interface { type TokenIdentityConnector interface { TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (Identity, error) } + +// LogoutCallbackConnector is a connector that can initiate upstream logout and +// optionally validate the upstream provider's logout response. +// Connectors that implement this interface support RP-Initiated Logout by +// returning a URL that Dex should redirect the user to in order to terminate +// the upstream session. +type LogoutCallbackConnector interface { + // LogoutURL returns the upstream provider's logout URL. + // connectorData is the data stored during the user's authentication session. + // postLogoutRedirectURI is the URL the upstream provider should redirect back to after logout. + // Returns the upstream logout URL or empty string if upstream logout is not available. + LogoutURL(ctx context.Context, connectorData []byte, postLogoutRedirectURI string) (string, error) + + // HandleLogoutCallback validates the upstream provider's logout response + // received in the callback request. For example, SAML connectors should + // verify the LogoutResponse signature and status code here. + // Connectors that don't receive a structured response (e.g. OIDC) should + // return nil. + HandleLogoutCallback(ctx context.Context, r *http.Request) error +} diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 8e1fe724..07e371dd 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -136,10 +136,13 @@ type ProviderDiscoveryOverrides struct { // JWKSURL provides a way to user overwrite the JWKS URL // from the .well-known/openid-configuration jwks_uri JWKSURL string `json:"jwksURL"` + // EndSessionURL provides a way to override the end_session_endpoint + // from the .well-known/openid-configuration + EndSessionURL string `json:"endSessionURL"` } func (o *ProviderDiscoveryOverrides) Empty() bool { - return o.TokenURL == "" && o.AuthURL == "" && o.JWKSURL == "" + return o.TokenURL == "" && o.AuthURL == "" && o.JWKSURL == "" && o.EndSessionURL == "" } func getProvider(ctx context.Context, issuer string, overrides ProviderDiscoveryOverrides) (*oidc.Provider, error) { @@ -319,9 +322,10 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, } } - // Obtain CodeChallengeMethodsSupported from the provider + // Obtain metadata from the provider var metadata struct { CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + EndSessionEndpoint string `json:"end_session_endpoint"` } if err := provider.Claims(&metadata); err != nil { logger.Warn("failed to parse provider metadata") @@ -340,6 +344,22 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, } } + endSessionURL := metadata.EndSessionEndpoint + if c.ProviderDiscoveryOverrides.EndSessionURL != "" { + endSessionURL = c.ProviderDiscoveryOverrides.EndSessionURL + } + if endSessionURL != "" { + endSessionParsed, err := url.Parse(endSessionURL) + if err != nil { + cancel() + return nil, fmt.Errorf("oidc: invalid end_session_endpoint: %v", err) + } + if endSessionParsed.Scheme != "https" && endSessionParsed.Scheme != "http" { + cancel() + return nil, fmt.Errorf("oidc: end_session_endpoint must use http or https scheme, got %q", endSessionParsed.Scheme) + } + } + clientID := c.ClientID return &oidcConnector{ provider: provider, @@ -375,13 +395,15 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, pkceChallenge: c.PKCEChallenge, + endSessionURL: endSessionURL, }, nil } var ( - _ connector.CallbackConnector = (*oidcConnector)(nil) - _ connector.RefreshConnector = (*oidcConnector)(nil) - _ connector.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.CallbackConnector = (*oidcConnector)(nil) + _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.LogoutCallbackConnector = (*oidcConnector)(nil) ) type oidcConnector struct { @@ -409,6 +431,7 @@ type oidcConnector struct { groupsPrefix string groupsSuffix string pkceChallenge string + endSessionURL string } func (c *oidcConnector) Close() error { @@ -739,3 +762,32 @@ func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.I return identity, nil } + +// LogoutURL returns the upstream OIDC provider's end_session_endpoint URL. +// Per the OIDC RP-Initiated Logout spec, the post_logout_redirect_uri parameter +// tells the upstream where to redirect after logout. +func (c *oidcConnector) LogoutURL(_ context.Context, _ []byte, postLogoutRedirectURI string) (string, error) { + if c.endSessionURL == "" { + return "", nil + } + + u, err := url.Parse(c.endSessionURL) + if err != nil { + return "", fmt.Errorf("oidc: failed to parse end_session_endpoint: %v", err) + } + + q := u.Query() + if postLogoutRedirectURI != "" { + q.Set("post_logout_redirect_uri", postLogoutRedirectURI) + q.Set("client_id", c.oauth2Config.ClientID) + } + u.RawQuery = q.Encode() + + return u.String(), nil +} + +// HandleLogoutCallback is a no-op for OIDC. The end_session_endpoint simply +// redirects back without a structured response to validate. +func (c *oidcConnector) HandleLogoutCallback(_ context.Context, _ *http.Request) error { + return nil +} diff --git a/connector/oidc/oidc_test.go b/connector/oidc/oidc_test.go index 71a30b6e..77473b0d 100644 --- a/connector/oidc/oidc_test.go +++ b/connector/oidc/oidc_test.go @@ -20,6 +20,7 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/dexidp/dex/connector" ) @@ -976,3 +977,108 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %+v to equal %+v", a, b) } } + +func TestLogoutURL(t *testing.T) { + tests := []struct { + name string + endSessionURL string + postLogoutRedirectURI string + wantURL string + wantEmpty bool + }{ + { + name: "no end_session_endpoint", + endSessionURL: "", + wantEmpty: true, + }, + { + name: "with end_session_endpoint, no redirect", + endSessionURL: "https://provider.example.com/logout", + wantURL: "https://provider.example.com/logout", + }, + { + name: "with end_session_endpoint and redirect", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://dex.example.com/logout/callback", + wantURL: "https://provider.example.com/logout?client_id=clientID&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Flogout%2Fcallback", + }, + { + name: "with existing query params", + endSessionURL: "https://provider.example.com/logout?existing=param", + postLogoutRedirectURI: "https://dex.example.com/callback", + wantURL: "https://provider.example.com/logout?client_id=clientID&existing=param&post_logout_redirect_uri=https%3A%2F%2Fdex.example.com%2Fcallback", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + conn := &oidcConnector{ + endSessionURL: tc.endSessionURL, + oauth2Config: &oauth2.Config{ + ClientID: "clientID", + }, + } + + got, err := conn.LogoutURL(context.Background(), nil, tc.postLogoutRedirectURI) + require.NoError(t, err) + + if tc.wantEmpty { + require.Empty(t, got) + return + } + + require.Equal(t, tc.wantURL, got) + }) + } +} + +func TestEndSessionURLDiscovery(t *testing.T) { + // Setup a server that advertises end_session_endpoint in discovery. + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(&map[string]interface{}{ + "keys": []map[string]interface{}{}, + }) + }) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + url := fmt.Sprintf("http://%s", r.Host) + json.NewEncoder(w).Encode(&map[string]string{ + "issuer": url, + "token_endpoint": fmt.Sprintf("%s/token", url), + "authorization_endpoint": fmt.Sprintf("%s/authorize", url), + "jwks_uri": fmt.Sprintf("%s/keys", url), + "end_session_endpoint": fmt.Sprintf("%s/logout", url), + }) + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + _ = key // We only need the server for discovery. + + conn, err := newConnector(Config{ + Issuer: ts.URL, + Scopes: []string{"openid"}, + }) + require.NoError(t, err) + require.Equal(t, fmt.Sprintf("%s/logout", ts.URL), conn.endSessionURL) +} + +func TestEndSessionURLOverride(t *testing.T) { + testServer, err := setupServer(nil, true) + require.NoError(t, err) + defer testServer.Close() + + conn, err := newConnector(Config{ + Issuer: testServer.URL, + Scopes: []string{"openid"}, + ProviderDiscoveryOverrides: ProviderDiscoveryOverrides{ + EndSessionURL: "https://custom.example.com/logout", + }, + }) + require.NoError(t, err) + require.Equal(t, "https://custom.example.com/logout", conn.endSessionURL) +} diff --git a/server/handlers.go b/server/handlers.go index 200d1415..825cf35e 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -82,6 +82,7 @@ type discovery struct { UserInfo string `json:"userinfo_endpoint"` DeviceEndpoint string `json:"device_authorization_endpoint"` Introspect string `json:"introspection_endpoint"` + EndSession string `json:"end_session_endpoint,omitempty"` GrantTypes []string `json:"grant_types_supported"` ResponseTypes []string `json:"response_types_supported"` Subjects []string `json:"subject_types_supported"` @@ -141,6 +142,11 @@ func (s *Server) constructDiscovery(ctx context.Context) discovery { sort.Strings(d.ResponseTypes) d.GrantTypes = s.supportedGrantTypes + + if s.sessionConfig != nil { + d.EndSession = s.absURL("/logout") + } + return d } diff --git a/server/logout.go b/server/logout.go new file mode 100644 index 00000000..c3a0699f --- /dev/null +++ b/server/logout.go @@ -0,0 +1,356 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/url" + "slices" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" +) + +// handleLogout implements OIDC RP-Initiated Logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +// +// GET/POST /logout?id_token_hint=...&post_logout_redirect_uri=...&state=... +// +// Flow: +// 1. Validate id_token_hint (signature + issuer; expiry skipped per spec) +// 2. Extract user identity (subject) and client (audience/azp) from the token +// 3. Validate post_logout_redirect_uri against the client's registered URIs +// 4. Revoke refresh tokens for the user/connector pair +// 5. If the auth session exists and upstream connector implements LogoutCallbackConnector: +// a. Store LogoutState + HMAC key in the session (not deleted yet) +// b. Redirect to upstream logout with signed state +// c. On callback: verify HMAC, read LogoutState from session, delete session, render page +// 6. If no session or no upstream logout support: delete session, clear cookie, render page +// +// Upstream redirect requires a live AuthSession because the session stores the +// HMAC key and logout parameters. Without a session (e.g. already expired, or +// id_token_hint without a cookie) upstream logout is skipped — this is acceptable +// because RP-Initiated Logout treats upstream SLO as best-effort. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.Method != http.MethodGet && r.Method != http.MethodPost { + s.renderError(r, w, http.StatusMethodNotAllowed, "Method not allowed.") + return + } + + idTokenHint := r.FormValue("id_token_hint") + postLogoutRedirectURI := r.FormValue("post_logout_redirect_uri") + state := r.FormValue("state") + + var userID, connectorID, clientID string + + if idTokenHint != "" { + idToken, err := s.validateIDTokenHint(ctx, idTokenHint) + if err != nil { + s.logger.ErrorContext(ctx, "logout: invalid id_token_hint", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint.") + return + } + + sub := new(internal.IDTokenSubject) + if err := internal.Unmarshal(idToken.Subject, sub); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to unmarshal subject", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint subject.") + return + } + + userID = sub.UserId + connectorID = sub.ConnId + + s.logger.DebugContext(ctx, "logout: parsed id_token_hint", + "user_id", userID, "connector_id", connectorID) + + // When cross-client (trusted peers) scopes are used, the token may have + // multiple audiences. In that case the requesting client is in the "azp" + // claim, not necessarily Audience[0]. Use the same logic as token introspection. + var claims struct { + AuthorizingParty string `json:"azp"` + } + if err := idToken.Claims(&claims); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to decode id_token_hint claims", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid id_token_hint.") + return + } + + switch len(idToken.Audience) { + case 0: + // No audience — cannot determine client. + case 1: + clientID = idToken.Audience[0] + default: + clientID = claims.AuthorizingParty + } + } + + // If no id_token_hint, try to identify the user from the session cookie. + // This allows logout without a hint when the user has an active session. + if userID == "" && connectorID == "" { + if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { + if uid, cid, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey); err == nil { + // Verify the session exists and nonce matches before trusting the cookie. + if session, err := s.storage.GetAuthSession(ctx, uid, cid); err == nil && session.Nonce == nonce { + userID = uid + connectorID = cid + s.logger.DebugContext(ctx, "logout: identified user from session cookie", + "user_id", userID, "connector_id", connectorID) + } + } + } + } + + // Validate post_logout_redirect_uri against registered client URIs. + if postLogoutRedirectURI != "" { + if clientID == "" { + s.renderError(r, w, http.StatusBadRequest, "post_logout_redirect_uri requires id_token_hint.") + return + } + + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + s.logger.ErrorContext(ctx, "logout: failed to get client", "client_id", clientID, "err", err) + s.renderError(r, w, http.StatusBadRequest, "Invalid client.") + return + } + + if !slices.Contains(client.PostLogoutRedirectURIs, postLogoutRedirectURI) { + s.logger.WarnContext(ctx, "logout: unregistered post_logout_redirect_uri", + "uri", postLogoutRedirectURI, "client_id", clientID) + s.renderError(r, w, http.StatusBadRequest, "Unregistered post_logout_redirect_uri.") + return + } + } + + // Revoke refresh tokens (does not touch the auth session or user identity). + var connectorData []byte + if userID != "" && connectorID != "" { + connectorData = s.revokeRefreshTokens(ctx, userID, connectorID) + } + + // Try upstream logout. This requires a live auth session to store the HMAC key + // and logout parameters. If the session doesn't exist (expired, no cookie, etc.) + // upstream logout is skipped — RP-Initiated Logout treats upstream SLO as best-effort. + if redirectURL, ok := s.tryUpstreamLogout(ctx, userID, connectorID, connectorData, postLogoutRedirectURI, state, clientID); ok { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + return + } + + // No upstream logout — delete session now, clear cookie, show page. + s.logger.DebugContext(ctx, "logout: completing", + "user_id", userID, "connector_id", connectorID, "client_id", clientID) + loggedOut := s.deleteAuthSession(ctx, userID, connectorID) + s.clearSessionCookie(w) + s.finishLogout(w, r, postLogoutRedirectURI, state, loggedOut) +} + +// handleLogoutCallback receives the redirect back from the upstream provider +// after it has completed its logout. +// +// Identity is resolved from the session cookie (HttpOnly, Secure, SameSite=Lax). +// The session must still exist in storage with a non-nil LogoutState (set before +// the upstream redirect). After validation, the session is deleted. +func (s *Server) handleLogoutCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Resolve identity from the session cookie. + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + s.renderError(r, w, http.StatusBadRequest, "Missing session cookie.") + return + } + + userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey) + if err != nil { + s.renderError(r, w, http.StatusBadRequest, "Invalid session cookie.") + return + } + + // Load the session and verify nonce. + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + s.logger.ErrorContext(ctx, "logout callback: session not found", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Session not found.") + return + } + + if session.Nonce != nonce { + s.renderError(r, w, http.StatusBadRequest, "Invalid session.") + return + } + + if session.LogoutState == nil { + s.renderError(r, w, http.StatusBadRequest, "No logout in progress.") + return + } + + ls := session.LogoutState + + // Let the connector validate the upstream logout response if it supports it. + if ls.ConnectorID != "" { + conn, err := s.getConnector(ctx, ls.ConnectorID) + if err == nil { + if logoutConn, ok := conn.Connector.(connector.LogoutCallbackConnector); ok { + if err := logoutConn.HandleLogoutCallback(ctx, r); err != nil { + s.logger.ErrorContext(ctx, "logout: upstream logout response validation failed", + "connector_id", ls.ConnectorID, "err", err) + } + } + } + } + + // Session kept alive until now — delete it and clear the cookie. + s.deleteAuthSession(ctx, userID, connectorID) + s.clearSessionCookie(w) + s.finishLogout(w, r, ls.PostLogoutRedirectURI, ls.State, true) +} + +// finishLogout renders the logout page with a "Back to Application" link. +// loggedOut indicates whether an active session was actually terminated. +func (s *Server) finishLogout(w http.ResponseWriter, r *http.Request, postLogoutRedirectURI, state string, loggedOut bool) { + var backURL string + if postLogoutRedirectURI != "" { + u, err := url.Parse(postLogoutRedirectURI) + if err == nil { + if state != "" { + q := u.Query() + q.Set("state", state) + u.RawQuery = q.Encode() + } + backURL = u.String() + } + } + + if err := s.templates.logout(r, w, backURL, loggedOut); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// tryUpstreamLogout attempts to redirect to the upstream provider's logout endpoint. +// It stores LogoutState in the auth session before redirecting so the callback can +// read it back. Returns the redirect URL and true on success, or ("", false) if +// upstream logout is not possible (no session, connector doesn't support it, etc.). +func (s *Server) tryUpstreamLogout(ctx context.Context, userID, connectorID string, connectorData []byte, postLogoutRedirectURI, state, clientID string) (string, bool) { + if connectorID == "" { + return "", false + } + + conn, err := s.getConnector(ctx, connectorID) + if err != nil { + return "", false + } + + logoutConn, ok := conn.Connector.(connector.LogoutCallbackConnector) + if !ok { + return "", false + } + + // Check that the session exists — we need it to store logout state. + _, err = s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + s.logger.DebugContext(ctx, "logout: no auth session for upstream logout, skipping", + "user_id", userID, "connector_id", connectorID) + return "", false + } + + // Store logout parameters in the session. + if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LogoutState = &storage.LogoutState{ + PostLogoutRedirectURI: postLogoutRedirectURI, + State: state, + ClientID: clientID, + ConnectorID: connectorID, + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to save logout state", "err", err) + return "", false + } + + callbackURI := s.absURL("/logout/callback") + upstreamURL, err := logoutConn.LogoutURL(ctx, connectorData, callbackURI) + if err != nil { + s.logger.ErrorContext(ctx, "logout: upstream connector error", "err", err) + return "", false + } + if upstreamURL == "" { + return "", false + } + + u, err := url.Parse(upstreamURL) + if err != nil { + s.logger.ErrorContext(ctx, "logout: failed to parse upstream URL", "err", err) + return "", false + } + + return u.String(), true +} + +// deleteAuthSession deletes the session and returns true if it existed. +func (s *Server) deleteAuthSession(ctx context.Context, userID, connectorID string) bool { + if userID == "" || connectorID == "" { + return false + } + if err := s.storage.DeleteAuthSession(ctx, userID, connectorID); err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to delete auth session", "err", err) + } + return false + } + s.logger.InfoContext(ctx, "logout successful", "user_id", userID, "connector_id", connectorID) + return true +} + +// revokeRefreshTokens deletes all refresh tokens for the given user/connector +// and clears the references in the offline session (but keeps the session object). +// Returns the connector data from the offline session (for upstream logout). +// +// To avoid a race condition where a new token issued between deletion and the +// OfflineSessions update would have its reference wiped, we: +// 1. Snapshot the token IDs to revoke +// 2. Remove only those specific references from OfflineSessions (the updater +// sees the latest state, so concurrently added refs are preserved) +// 3. Delete the actual refresh tokens +func (s *Server) revokeRefreshTokens(ctx context.Context, userID, connectorID string) []byte { + offlineSessions, err := s.storage.GetOfflineSessions(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to get offline sessions", "err", err) + } + return nil + } + + // Snapshot token IDs to revoke. + tokenIDs := make(map[string]struct{}, len(offlineSessions.Refresh)) + for _, ref := range offlineSessions.Refresh { + tokenIDs[ref.ID] = struct{}{} + } + + // Remove only the snapshotted references — any token added concurrently + // will not be in tokenIDs and will be left untouched. + if err := s.storage.UpdateOfflineSessions(ctx, userID, connectorID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + for clientID, ref := range old.Refresh { + if _, ok := tokenIDs[ref.ID]; ok { + delete(old.Refresh, clientID) + } + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "logout: failed to update offline sessions", "err", err) + } + + // Delete the actual refresh tokens. + for id := range tokenIDs { + if err := s.storage.DeleteRefresh(ctx, id); err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "logout: failed to delete refresh token", "token_id", id, "err", err) + } + } + } + + return offlineSessions.ConnectorData +} diff --git a/server/logout_test.go b/server/logout_test.go new file mode 100644 index 00000000..4c55b236 --- /dev/null +++ b/server/logout_test.go @@ -0,0 +1,326 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" +) + +func TestHandleLogoutNoSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestHandleLogoutMethodNotAllowed(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} + +func TestHandleLogoutPOST(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "No active session") +} + +func TestHandleLogoutNoHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "No active session") + require.NotContains(t, body, "successfully logged out") +} + +func TestHandleLogoutInvalidHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/logout?id_token_hint=invalid-token", nil) + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutWithValidHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: "testnonce", + CreatedAt: time.Now(), + LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s&state=mystate", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + require.Contains(t, body, "successfully logged out") + require.Contains(t, body, "Back to Application") + require.Contains(t, body, postLogoutURI) + require.Contains(t, body, "state=mystate") + + // Session deleted. + _, err = server.storage.GetAuthSession(ctx, userID, connectorID) + require.ErrorIs(t, err, storage.ErrNotFound) + + // Cookie cleared. + for _, c := range rr.Result().Cookies() { + if c.Name == "dex_session" { + require.Equal(t, -1, c.MaxAge) + } + } +} + +func TestHandleLogoutUnregisteredRedirectURI(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{"https://example.com/done"}, + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: "user1", Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", "mock", time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape("https://evil.com/steal")) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutRedirectURIWithoutHint(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout?post_logout_redirect_uri=https://example.com/done", nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleLogoutRevokesRefreshTokens(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, + Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + refreshID := storage.NewID() + require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "token-value", ClientID: clientID, ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID, Username: "testuser", Email: "test@example.com"}, + Scopes: []string{"openid", "offline_access"}, CreatedAt: time.Now(), LastUsed: time.Now(), + })) + + require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: userID, ConnID: connectorID, + Refresh: map[string]*storage.RefreshTokenRef{ + clientID: {ID: refreshID, ClientID: clientID, CreatedAt: time.Now(), LastUsed: time.Now()}, + }, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "testnonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + + _, err = server.storage.GetRefresh(ctx, refreshID) + require.ErrorIs(t, err, storage.ErrNotFound) + + os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID) + require.NoError(t, err) + require.Empty(t, os.Refresh) +} + +func TestHandleLogoutRepeat(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + clientID := "test-client" + postLogoutURI := "https://example.com/done" + userID := "test-user" + connectorID := "mock" + + require.NoError(t, server.storage.CreateClient(ctx, storage.Client{ + ID: clientID, Secret: "secret", + RedirectURIs: []string{"https://example.com/callback"}, + PostLogoutRedirectURIs: []string{postLogoutURI}, + })) + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, ConnectorID: connectorID, Nonce: "testnonce", + CreatedAt: time.Now(), LastActivity: time.Now(), + })) + + idToken, _, err := server.newIDToken(ctx, clientID, storage.Claims{ + UserID: userID, Username: "testuser", Email: "test@example.com", + }, []string{"openid"}, "", "", "", connectorID, time.Now()) + require.NoError(t, err) + + logoutURL := fmt.Sprintf("/logout?id_token_hint=%s&post_logout_redirect_uri=%s", + url.QueryEscape(idToken), url.QueryEscape(postLogoutURI)) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "successfully logged out") + + // Second logout — session already deleted, shows "no active session". + rr = httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", logoutURL, nil)) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), "No active session") +} + +func TestLogoutCallbackNoState(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/logout/callback", nil)) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestDiscoveryWithSessions(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var d discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d)) + require.Equal(t, fmt.Sprintf("%s/logout", httpServer.URL), d.EndSession) +} + +func TestDiscoveryWithoutSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/.well-known/openid-configuration", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + var d discovery + require.NoError(t, json.NewDecoder(rr.Result().Body).Decode(&d)) + require.Empty(t, d.EndSession) +} + +func TestRevokeRefreshTokensReturnsConnectorData(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := context.Background() + userID := "user1" + connectorID := "mock" + expectedConnData := []byte(`{"RefreshToken":"abc"}`) + + refreshID := storage.NewID() + require.NoError(t, server.storage.CreateRefresh(ctx, storage.RefreshToken{ + ID: refreshID, Token: "tok", ClientID: "client1", ConnectorID: connectorID, + Claims: storage.Claims{UserID: userID}, CreatedAt: time.Now(), LastUsed: time.Now(), + })) + + require.NoError(t, server.storage.CreateOfflineSessions(ctx, storage.OfflineSessions{ + UserID: userID, ConnID: connectorID, ConnectorData: expectedConnData, + Refresh: map[string]*storage.RefreshTokenRef{"client1": {ID: refreshID, ClientID: "client1"}}, + })) + + connData := server.revokeRefreshTokens(ctx, userID, connectorID) + require.Equal(t, expectedConnData, connData) + + _, err := server.storage.GetRefresh(ctx, refreshID) + require.ErrorIs(t, err, storage.ErrNotFound) + + os, err := server.storage.GetOfflineSessions(ctx, userID, connectorID) + require.NoError(t, err) + require.Empty(t, os.Refresh) + require.Equal(t, expectedConnData, os.ConnectorData) +} diff --git a/server/oauth2.go b/server/oauth2.go index 40f8063b..6e91facf 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -444,8 +444,8 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage // validateIDTokenHint verifies the signature and issuer of an id_token_hint. // Expired tokens are accepted per OIDC Core 1.0 §3.1.2.1. -// Returns the raw subject claim from the token. -func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (string, error) { +// Returns the verified token so callers can extract Subject, Audience, etc. +func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (*oidc.IDToken, error) { verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{ SkipExpiryCheck: true, // SkipClientIDCheck is set because the hint may originate from any client that @@ -455,11 +455,7 @@ func (s *Server) validateIDTokenHint(ctx context.Context, hint string) (string, // Dex does the client id check later in the scope of the session validation. SkipClientIDCheck: true, }) - idToken, err := verifier.Verify(ctx, hint) - if err != nil { - return "", err - } - return idToken.Subject, nil + return verifier.Verify(ctx, hint) } // sessionMatchesHint checks whether the session's user identity matches the @@ -662,11 +658,11 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques // Validate id_token_hint if provided (OIDC Core 1.0 §3.1.2.1). var idTokenHintSubject string if hint := q.Get("id_token_hint"); hint != "" { - sub, err := s.validateIDTokenHint(ctx, hint) + idToken, err := s.validateIDTokenHint(ctx, hint) if err != nil { return nil, "", newRedirectedErr(errInvalidRequest, "Invalid id_token_hint.") } - idTokenHintSubject = sub + idTokenHintSubject = idToken.Subject } return &storage.AuthRequest{ diff --git a/server/oauth2_test.go b/server/oauth2_test.go index 2cdc938a..403b497b 100644 --- a/server/oauth2_test.go +++ b/server/oauth2_test.go @@ -952,9 +952,9 @@ func TestValidateIDTokenHint(t *testing.T) { Subject: "CgNmb28SA2Jhcg", Expiry: now.Add(1 * time.Hour).Unix(), }) - sub, err := s.validateIDTokenHint(t.Context(), token) + idToken, err := s.validateIDTokenHint(t.Context(), token) require.NoError(t, err) - assert.Equal(t, "CgNmb28SA2Jhcg", sub) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) }) t.Run("valid hint (expired)", func(t *testing.T) { @@ -963,9 +963,9 @@ func TestValidateIDTokenHint(t *testing.T) { Subject: "CgNmb28SA2Jhcg", Expiry: now.Add(-1 * time.Hour).Unix(), }) - sub, err := s.validateIDTokenHint(t.Context(), token) + idToken, err := s.validateIDTokenHint(t.Context(), token) require.NoError(t, err) - assert.Equal(t, "CgNmb28SA2Jhcg", sub) + assert.Equal(t, "CgNmb28SA2Jhcg", idToken.Subject) }) t.Run("invalid signature", func(t *testing.T) { diff --git a/server/server.go b/server/server.go index 2f023623..711ef7f0 100644 --- a/server/server.go +++ b/server/server.go @@ -551,6 +551,10 @@ func newServer(ctx context.Context, c Config) (*Server, error) { // "authproxy" connector. handleFunc("/callback/{connector}", s.handleConnectorCallback) handleFunc("/approval", s.handleApproval) + if c.SessionConfig != nil { + handleFunc("/logout", s.handleLogout) + handleFunc("/logout/callback", s.handleLogoutCallback) + } handleFunc("/mfa/verify", s.handleMFAVerify) handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !c.HealthChecker.IsHealthy() { diff --git a/server/templates.go b/server/templates.go index a2818b28..dd94025d 100644 --- a/server/templates.go +++ b/server/templates.go @@ -24,6 +24,7 @@ const ( tmplDeviceSuccess = "device_success.html" tmplTOTPVerify = "totp_verify.html" tmplHome = "home.html" + tmplLogout = "logout.html" ) var requiredTmpls = []string{ @@ -46,6 +47,7 @@ type templates struct { deviceSuccessTmpl *template.Template totpVerifyTmpl *template.Template homeTmpl *template.Template + logoutTmpl *template.Template } type webConfig struct { @@ -175,6 +177,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), homeTmpl: tmpls.Lookup(tmplHome), + logoutTmpl: tmpls.Lookup(tmplLogout), }, nil } @@ -386,6 +389,15 @@ func (t *templates) home(r *http.Request, w http.ResponseWriter, data homeData) return renderTemplate(w, t.homeTmpl, data) } +func (t *templates) logout(r *http.Request, w http.ResponseWriter, backURL string, loggedOut bool) error { + data := struct { + BackURL string + LoggedOut bool + ReqPath string + }{backURL, loggedOut, r.URL.Path} + return renderTemplate(w, t.logoutTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/storage/ent/client/client.go b/storage/ent/client/client.go index 4c18939d..20b0f9f9 100644 --- a/storage/ent/client/client.go +++ b/storage/ent/client/client.go @@ -18,6 +18,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro SetTrustedPeers(client.TrustedPeers). SetAllowedConnectors(client.AllowedConnectors). SetMfaChain(client.MFAChain). + SetPostLogoutRedirectUris(client.PostLogoutRedirectURIs). Save(ctx) if err != nil { return convertDBError("create oauth2 client: %w", err) @@ -83,6 +84,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old SetTrustedPeers(newClient.TrustedPeers). SetAllowedConnectors(newClient.AllowedConnectors). SetMfaChain(newClient.MFAChain). + SetPostLogoutRedirectUris(newClient.PostLogoutRedirectURIs). Save(ctx) if err != nil { return rollback(tx, "update client uploading: %w", err) diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index 91689619..14f248fa 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -81,15 +81,16 @@ func toStorageAuthCode(a *db.AuthCode) storage.AuthCode { func toStorageClient(c *db.OAuth2Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectUris, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MfaChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectUris, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MfaChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectUris, } } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index e8958ec9..bf1d086d 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -162,6 +162,7 @@ var ( {Name: "logo_url", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true}, {Name: "mfa_chain", Type: field.TypeJSON, Nullable: true}, + {Name: "post_logout_redirect_uris", Type: field.TypeJSON, Nullable: true}, } // Oauth2clientsTable holds the schema information for the "oauth2clients" table. Oauth2clientsTable = &schema.Table{ diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 4c7858f0..ddd3c5b3 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -6380,25 +6380,27 @@ func (m *KeysMutation) ResetEdge(name string) error { // OAuth2ClientMutation represents an operation that mutates the OAuth2Client nodes in the graph. type OAuth2ClientMutation struct { config - op Op - typ string - id *string - secret *string - redirect_uris *[]string - appendredirect_uris []string - trusted_peers *[]string - appendtrusted_peers []string - public *bool - name *string - logo_url *string - allowed_connectors *[]string - appendallowed_connectors []string - mfa_chain *[]string - appendmfa_chain []string - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*OAuth2Client, error) - predicates []predicate.OAuth2Client + op Op + typ string + id *string + secret *string + redirect_uris *[]string + appendredirect_uris []string + trusted_peers *[]string + appendtrusted_peers []string + public *bool + name *string + logo_url *string + allowed_connectors *[]string + appendallowed_connectors []string + mfa_chain *[]string + appendmfa_chain []string + post_logout_redirect_uris *[]string + appendpost_logout_redirect_uris []string + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*OAuth2Client, error) + predicates []predicate.OAuth2Client } var _ ent.Mutation = (*OAuth2ClientMutation)(nil) @@ -6909,6 +6911,71 @@ func (m *OAuth2ClientMutation) ResetMfaChain() { delete(m.clearedFields, oauth2client.FieldMfaChain) } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) SetPostLogoutRedirectUris(s []string) { + m.post_logout_redirect_uris = &s + m.appendpost_logout_redirect_uris = nil +} + +// PostLogoutRedirectUris returns the value of the "post_logout_redirect_uris" field in the mutation. +func (m *OAuth2ClientMutation) PostLogoutRedirectUris() (r []string, exists bool) { + v := m.post_logout_redirect_uris + if v == nil { + return + } + return *v, true +} + +// OldPostLogoutRedirectUris returns the old "post_logout_redirect_uris" field's value of the OAuth2Client entity. +// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OAuth2ClientMutation) OldPostLogoutRedirectUris(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPostLogoutRedirectUris is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPostLogoutRedirectUris requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPostLogoutRedirectUris: %w", err) + } + return oldValue.PostLogoutRedirectUris, nil +} + +// AppendPostLogoutRedirectUris adds s to the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) AppendPostLogoutRedirectUris(s []string) { + m.appendpost_logout_redirect_uris = append(m.appendpost_logout_redirect_uris, s...) +} + +// AppendedPostLogoutRedirectUris returns the list of values that were appended to the "post_logout_redirect_uris" field in this mutation. +func (m *OAuth2ClientMutation) AppendedPostLogoutRedirectUris() ([]string, bool) { + if len(m.appendpost_logout_redirect_uris) == 0 { + return nil, false + } + return m.appendpost_logout_redirect_uris, true +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) ClearPostLogoutRedirectUris() { + m.post_logout_redirect_uris = nil + m.appendpost_logout_redirect_uris = nil + m.clearedFields[oauth2client.FieldPostLogoutRedirectUris] = struct{}{} +} + +// PostLogoutRedirectUrisCleared returns if the "post_logout_redirect_uris" field was cleared in this mutation. +func (m *OAuth2ClientMutation) PostLogoutRedirectUrisCleared() bool { + _, ok := m.clearedFields[oauth2client.FieldPostLogoutRedirectUris] + return ok +} + +// ResetPostLogoutRedirectUris resets all changes to the "post_logout_redirect_uris" field. +func (m *OAuth2ClientMutation) ResetPostLogoutRedirectUris() { + m.post_logout_redirect_uris = nil + m.appendpost_logout_redirect_uris = nil + delete(m.clearedFields, oauth2client.FieldPostLogoutRedirectUris) +} + // Where appends a list predicates to the OAuth2ClientMutation builder. func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) { m.predicates = append(m.predicates, ps...) @@ -6943,7 +7010,7 @@ func (m *OAuth2ClientMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OAuth2ClientMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 9) if m.secret != nil { fields = append(fields, oauth2client.FieldSecret) } @@ -6968,6 +7035,9 @@ func (m *OAuth2ClientMutation) Fields() []string { if m.mfa_chain != nil { fields = append(fields, oauth2client.FieldMfaChain) } + if m.post_logout_redirect_uris != nil { + fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) + } return fields } @@ -6992,6 +7062,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) { return m.AllowedConnectors() case oauth2client.FieldMfaChain: return m.MfaChain() + case oauth2client.FieldPostLogoutRedirectUris: + return m.PostLogoutRedirectUris() } return nil, false } @@ -7017,6 +7089,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V return m.OldAllowedConnectors(ctx) case oauth2client.FieldMfaChain: return m.OldMfaChain(ctx) + case oauth2client.FieldPostLogoutRedirectUris: + return m.OldPostLogoutRedirectUris(ctx) } return nil, fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7082,6 +7156,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error { } m.SetMfaChain(v) return nil + case oauth2client.FieldPostLogoutRedirectUris: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPostLogoutRedirectUris(v) + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } @@ -7124,6 +7205,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string { if m.FieldCleared(oauth2client.FieldMfaChain) { fields = append(fields, oauth2client.FieldMfaChain) } + if m.FieldCleared(oauth2client.FieldPostLogoutRedirectUris) { + fields = append(fields, oauth2client.FieldPostLogoutRedirectUris) + } return fields } @@ -7150,6 +7234,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error { case oauth2client.FieldMfaChain: m.ClearMfaChain() return nil + case oauth2client.FieldPostLogoutRedirectUris: + m.ClearPostLogoutRedirectUris() + return nil } return fmt.Errorf("unknown OAuth2Client nullable field %s", name) } @@ -7182,6 +7269,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error { case oauth2client.FieldMfaChain: m.ResetMfaChain() return nil + case oauth2client.FieldPostLogoutRedirectUris: + m.ResetPostLogoutRedirectUris() + return nil } return fmt.Errorf("unknown OAuth2Client field %s", name) } diff --git a/storage/ent/db/oauth2client.go b/storage/ent/db/oauth2client.go index 81a87afc..990edc97 100644 --- a/storage/ent/db/oauth2client.go +++ b/storage/ent/db/oauth2client.go @@ -32,8 +32,10 @@ type OAuth2Client struct { // AllowedConnectors holds the value of the "allowed_connectors" field. AllowedConnectors []string `json:"allowed_connectors,omitempty"` // MfaChain holds the value of the "mfa_chain" field. - MfaChain []string `json:"mfa_chain,omitempty"` - selectValues sql.SelectValues + MfaChain []string `json:"mfa_chain,omitempty"` + // PostLogoutRedirectUris holds the value of the "post_logout_redirect_uris" field. + PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"` + selectValues sql.SelectValues } // scanValues returns the types for scanning values from sql.Rows. @@ -41,7 +43,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain: + case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris: values[i] = new([]byte) case oauth2client.FieldPublic: values[i] = new(sql.NullBool) @@ -124,6 +126,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field mfa_chain: %w", err) } } + case oauth2client.FieldPostLogoutRedirectUris: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field post_logout_redirect_uris", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.PostLogoutRedirectUris); err != nil { + return fmt.Errorf("unmarshal field post_logout_redirect_uris: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -183,6 +193,9 @@ func (_m *OAuth2Client) String() string { builder.WriteString(", ") builder.WriteString("mfa_chain=") builder.WriteString(fmt.Sprintf("%v", _m.MfaChain)) + builder.WriteString(", ") + builder.WriteString("post_logout_redirect_uris=") + builder.WriteString(fmt.Sprintf("%v", _m.PostLogoutRedirectUris)) builder.WriteByte(')') return builder.String() } diff --git a/storage/ent/db/oauth2client/oauth2client.go b/storage/ent/db/oauth2client/oauth2client.go index 0143eb6e..9474f0bd 100644 --- a/storage/ent/db/oauth2client/oauth2client.go +++ b/storage/ent/db/oauth2client/oauth2client.go @@ -27,6 +27,8 @@ const ( FieldAllowedConnectors = "allowed_connectors" // FieldMfaChain holds the string denoting the mfa_chain field in the database. FieldMfaChain = "mfa_chain" + // FieldPostLogoutRedirectUris holds the string denoting the post_logout_redirect_uris field in the database. + FieldPostLogoutRedirectUris = "post_logout_redirect_uris" // Table holds the table name of the oauth2client in the database. Table = "oauth2clients" ) @@ -42,6 +44,7 @@ var Columns = []string{ FieldLogoURL, FieldAllowedConnectors, FieldMfaChain, + FieldPostLogoutRedirectUris, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/storage/ent/db/oauth2client/where.go b/storage/ent/db/oauth2client/where.go index d8ce00d6..f23967a7 100644 --- a/storage/ent/db/oauth2client/where.go +++ b/storage/ent/db/oauth2client/where.go @@ -327,6 +327,16 @@ func MfaChainNotNil() predicate.OAuth2Client { return predicate.OAuth2Client(sql.FieldNotNull(FieldMfaChain)) } +// PostLogoutRedirectUrisIsNil applies the IsNil predicate on the "post_logout_redirect_uris" field. +func PostLogoutRedirectUrisIsNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldIsNull(FieldPostLogoutRedirectUris)) +} + +// PostLogoutRedirectUrisNotNil applies the NotNil predicate on the "post_logout_redirect_uris" field. +func PostLogoutRedirectUrisNotNil() predicate.OAuth2Client { + return predicate.OAuth2Client(sql.FieldNotNull(FieldPostLogoutRedirectUris)) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client { return predicate.OAuth2Client(sql.AndPredicates(predicates...)) diff --git a/storage/ent/db/oauth2client_create.go b/storage/ent/db/oauth2client_create.go index 1e8d2a54..da576440 100644 --- a/storage/ent/db/oauth2client_create.go +++ b/storage/ent/db/oauth2client_create.go @@ -67,6 +67,12 @@ func (_c *OAuth2ClientCreate) SetMfaChain(v []string) *OAuth2ClientCreate { return _c } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_c *OAuth2ClientCreate) SetPostLogoutRedirectUris(v []string) *OAuth2ClientCreate { + _c.mutation.SetPostLogoutRedirectUris(v) + return _c +} + // SetID sets the "id" field. func (_c *OAuth2ClientCreate) SetID(v string) *OAuth2ClientCreate { _c.mutation.SetID(v) @@ -206,6 +212,10 @@ func (_c *OAuth2ClientCreate) createSpec() (*OAuth2Client, *sqlgraph.CreateSpec) _spec.SetField(oauth2client.FieldMfaChain, field.TypeJSON, value) _node.MfaChain = value } + if value, ok := _c.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + _node.PostLogoutRedirectUris = value + } return _node, _spec } diff --git a/storage/ent/db/oauth2client_update.go b/storage/ent/db/oauth2client_update.go index 7e9f753c..efa92c4c 100644 --- a/storage/ent/db/oauth2client_update.go +++ b/storage/ent/db/oauth2client_update.go @@ -156,6 +156,24 @@ func (_u *OAuth2ClientUpdate) ClearMfaChain() *OAuth2ClientUpdate { return _u } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) SetPostLogoutRedirectUris(v []string) *OAuth2ClientUpdate { + _u.mutation.SetPostLogoutRedirectUris(v) + return _u +} + +// AppendPostLogoutRedirectUris appends value to the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) AppendPostLogoutRedirectUris(v []string) *OAuth2ClientUpdate { + _u.mutation.AppendPostLogoutRedirectUris(v) + return _u +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdate) ClearPostLogoutRedirectUris() *OAuth2ClientUpdate { + _u.mutation.ClearPostLogoutRedirectUris() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdate) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -276,6 +294,17 @@ func (_u *OAuth2ClientUpdate) sqlSave(ctx context.Context) (_node int, err error if _u.mutation.MfaChainCleared() { _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) } + if value, ok := _u.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedPostLogoutRedirectUris(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldPostLogoutRedirectUris, value) + }) + } + if _u.mutation.PostLogoutRedirectUrisCleared() { + _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{oauth2client.Label} @@ -424,6 +453,24 @@ func (_u *OAuth2ClientUpdateOne) ClearMfaChain() *OAuth2ClientUpdateOne { return _u } +// SetPostLogoutRedirectUris sets the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) SetPostLogoutRedirectUris(v []string) *OAuth2ClientUpdateOne { + _u.mutation.SetPostLogoutRedirectUris(v) + return _u +} + +// AppendPostLogoutRedirectUris appends value to the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) AppendPostLogoutRedirectUris(v []string) *OAuth2ClientUpdateOne { + _u.mutation.AppendPostLogoutRedirectUris(v) + return _u +} + +// ClearPostLogoutRedirectUris clears the value of the "post_logout_redirect_uris" field. +func (_u *OAuth2ClientUpdateOne) ClearPostLogoutRedirectUris() *OAuth2ClientUpdateOne { + _u.mutation.ClearPostLogoutRedirectUris() + return _u +} + // Mutation returns the OAuth2ClientMutation object of the builder. func (_u *OAuth2ClientUpdateOne) Mutation() *OAuth2ClientMutation { return _u.mutation @@ -574,6 +621,17 @@ func (_u *OAuth2ClientUpdateOne) sqlSave(ctx context.Context) (_node *OAuth2Clie if _u.mutation.MfaChainCleared() { _spec.ClearField(oauth2client.FieldMfaChain, field.TypeJSON) } + if value, ok := _u.mutation.PostLogoutRedirectUris(); ok { + _spec.SetField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedPostLogoutRedirectUris(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, oauth2client.FieldPostLogoutRedirectUris, value) + }) + } + if _u.mutation.PostLogoutRedirectUrisCleared() { + _spec.ClearField(oauth2client.FieldPostLogoutRedirectUris, field.TypeJSON) + } _node = &OAuth2Client{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/storage/ent/schema/client.go b/storage/ent/schema/client.go index c7737d70..882842ce 100644 --- a/storage/ent/schema/client.go +++ b/storage/ent/schema/client.go @@ -49,6 +49,8 @@ func (OAuth2Client) Fields() []ent.Field { Optional(), field.JSON("mfa_chain", []string{}). Optional(), + field.JSON("post_logout_redirect_uris", []string{}). + Optional(), } } diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index c945874a..5d40acb2 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -289,6 +289,8 @@ type Client struct { AllowedConnectors []string `json:"allowedConnectors,omitempty"` MFAChain []string `json:"mfaChain,omitempty"` + + PostLogoutRedirectURIs []string `json:"postLogoutRedirectURIs,omitempty"` } // ClientList is a list of Clients. @@ -308,29 +310,31 @@ func (cli *client) fromStorageClient(c storage.Client) Client { Name: cli.idToName(c.ID), Namespace: cli.namespace, }, - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MFAChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, } } func toStorageClient(c Client) storage.Client { return storage.Client{ - ID: c.ID, - Secret: c.Secret, - RedirectURIs: c.RedirectURIs, - TrustedPeers: c.TrustedPeers, - Public: c.Public, - Name: c.Name, - LogoURL: c.LogoURL, - AllowedConnectors: c.AllowedConnectors, - MFAChain: c.MFAChain, + ID: c.ID, + Secret: c.Secret, + RedirectURIs: c.RedirectURIs, + TrustedPeers: c.TrustedPeers, + Public: c.Public, + Name: c.Name, + LogoURL: c.LogoURL, + AllowedConnectors: c.AllowedConnectors, + MFAChain: c.MFAChain, + PostLogoutRedirectURIs: c.PostLogoutRedirectURIs, } } @@ -1006,6 +1010,7 @@ type AuthSession struct { UserAgent string `json:"userAgent,omitempty"` AbsoluteExpiry time.Time `json:"absoluteExpiry,omitempty"` IdleExpiry time.Time `json:"idleExpiry,omitempty"` + LogoutState *storage.LogoutState `json:"logoutState,omitempty"` } // AuthSessionList is a list of AuthSessions. @@ -1035,6 +1040,7 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + LogoutState: s.LogoutState, } } @@ -1050,6 +1056,7 @@ func toStorageAuthSession(s AuthSession) storage.AuthSession { UserAgent: s.UserAgent, AbsoluteExpiry: s.AbsoluteExpiry, IdleExpiry: s.IdleExpiry, + LogoutState: s.LogoutState, } if result.ClientStates == nil { result.ClientStates = make(map[string]*storage.ClientAuthState) diff --git a/storage/sql/crud.go b/storage/sql/crud.go index 83308672..2864ebb3 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -539,9 +539,10 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto name = $5, logo_url = $6, allowed_connectors = $7, - mfa_chain = $8 - where id = $9; - `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), id, + mfa_chain = $8, + post_logout_redirect_uris = $9 + where id = $10; + `, nc.Secret, encoder(nc.RedirectURIs), encoder(nc.TrustedPeers), nc.Public, nc.Name, nc.LogoURL, encoder(nc.AllowedConnectors), encoder(nc.MFAChain), encoder(nc.PostLogoutRedirectURIs), id, ) if err != nil { return fmt.Errorf("update client: %v", err) @@ -553,12 +554,12 @@ func (c *conn) UpdateClient(ctx context.Context, id string, updater func(old sto func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { _, err := c.Exec(` insert into client ( - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); `, cli.ID, cli.Secret, encoder(cli.RedirectURIs), encoder(cli.TrustedPeers), - cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), + cli.Public, cli.Name, cli.LogoURL, encoder(cli.AllowedConnectors), encoder(cli.MFAChain), encoder(cli.PostLogoutRedirectURIs), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -572,7 +573,7 @@ func (c *conn) CreateClient(ctx context.Context, cli storage.Client) error { func getClient(ctx context.Context, q querier, id string) (storage.Client, error) { return scanClient(q.QueryRow(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris from client where id = $1; `, id)) } @@ -584,7 +585,7 @@ func (c *conn) GetClient(ctx context.Context, id string) (storage.Client, error) func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { rows, err := c.Query(` select - id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain + id, secret, redirect_uris, trusted_peers, public, name, logo_url, allowed_connectors, mfa_chain, post_logout_redirect_uris from client; `) if err != nil { @@ -609,9 +610,10 @@ func (c *conn) ListClients(ctx context.Context) ([]storage.Client, error) { func scanClient(s scanner) (cli storage.Client, err error) { var allowedConnectors []byte var mfaChain []byte + var postLogoutRedirectURIs []byte err = s.Scan( &cli.ID, &cli.Secret, decoder(&cli.RedirectURIs), decoder(&cli.TrustedPeers), - &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, + &cli.Public, &cli.Name, &cli.LogoURL, &allowedConnectors, &mfaChain, &postLogoutRedirectURIs, ) if err != nil { if err == sql.ErrNoRows { @@ -629,6 +631,11 @@ func scanClient(s scanner) (cli storage.Client, err error) { return cli, fmt.Errorf("unmarshal client mfa chain: %v", err) } } + if len(postLogoutRedirectURIs) > 0 { + if err := json.Unmarshal(postLogoutRedirectURIs, &cli.PostLogoutRedirectURIs); err != nil { + return cli, fmt.Errorf("unmarshal client post logout redirect uris: %v", err) + } + } return cli, nil } @@ -971,15 +978,17 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err client_states, created_at, last_activity, ip_address, user_agent, - absolute_expiry, idle_expiry + absolute_expiry, idle_expiry, + logout_state ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); `, s.UserID, s.ConnectorID, s.Nonce, encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, s.AbsoluteExpiry, s.IdleExpiry, + encoder(s.LogoutState), ) if err != nil { if c.alreadyExistsCheck(err) { @@ -1007,12 +1016,14 @@ func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string client_states = $1, last_activity = $2, ip_address = $3, - user_agent = $4 - where user_id = $5 AND connector_id = $6; + user_agent = $4, + logout_state = $5 + where user_id = $6 AND connector_id = $7; `, encoder(newSession.ClientStates), newSession.LastActivity, newSession.IPAddress, newSession.UserAgent, + encoder(newSession.LogoutState), userID, connectorID, ) if err != nil { @@ -1026,26 +1037,32 @@ func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) ( return getAuthSession(ctx, c, userID, connectorID) } +const authSessionColumns = ` + user_id, connector_id, nonce, + client_states, + created_at, last_activity, + ip_address, user_agent, + absolute_expiry, idle_expiry, + logout_state +` + func getAuthSession(ctx context.Context, q querier, userID, connectorID string) (storage.AuthSession, error) { return scanAuthSession(q.QueryRow(` - select - user_id, connector_id, nonce, - client_states, - created_at, last_activity, - ip_address, user_agent, - absolute_expiry, idle_expiry + select `+authSessionColumns+` from auth_session where user_id = $1 AND connector_id = $2; `, userID, connectorID)) } func scanAuthSession(s scanner) (session storage.AuthSession, err error) { + var logoutState []byte err = s.Scan( &session.UserID, &session.ConnectorID, &session.Nonce, decoder(&session.ClientStates), &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, &session.AbsoluteExpiry, &session.IdleExpiry, + &logoutState, ) if err != nil { if err == sql.ErrNoRows { @@ -1056,19 +1073,17 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { if session.ClientStates == nil { session.ClientStates = make(map[string]*storage.ClientAuthState) } + if len(logoutState) > 0 && string(logoutState) != "null" { + session.LogoutState = new(storage.LogoutState) + if err := json.Unmarshal(logoutState, session.LogoutState); err != nil { + return session, fmt.Errorf("unmarshal auth session logout state: %v", err) + } + } return session, nil } func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { - rows, err := c.Query(` - select - user_id, connector_id, nonce, - client_states, - created_at, last_activity, - ip_address, user_agent, - absolute_expiry, idle_expiry - from auth_session; - `) + rows, err := c.Query(`select ` + authSessionColumns + ` from auth_session;`) if err != nil { return nil, fmt.Errorf("query: %v", err) } diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 80b81b4f..942b119a 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -450,6 +450,8 @@ var migrations = []migration{ ` alter table auth_session add column idle_expiry timestamptz not null default '1970-01-01 00:00:00';`, + `alter table client add column post_logout_redirect_uris bytea;`, + `alter table auth_session add column logout_state bytea;`, }, }, } diff --git a/storage/storage.go b/storage/storage.go index 0889e6b6..21d1e971 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -170,6 +170,11 @@ type Client struct { // requested to redirect to MUST match one of these values, unless the client is "public". RedirectURIs []string `json:"redirectURIs"` + // PostLogoutRedirectURIs is a registered set of URIs that the client can redirect to + // after logout. Per OIDC RP-Initiated Logout Section 2, the post_logout_redirect_uri + // parameter MUST match one of these values. + PostLogoutRedirectURIs []string `json:"postLogoutRedirectURIs"` + // TrustedPeers are a list of peers which can issue tokens on this client's behalf using // the dynamic "oauth2:server:client_id:(client_id)" scope. If a peer makes such a request, // this client's ID will appear as the ID Token's audience. @@ -388,6 +393,15 @@ type ClientAuthState struct { LastTokenIssuedAt time.Time } +// LogoutState holds RP parameters saved in the auth session during logout. +// These are written before the upstream logout redirect and read back in the callback. +type LogoutState struct { + PostLogoutRedirectURI string + State string // RP's opaque state parameter + ClientID string + ConnectorID string +} + // AuthSession represents a user's authentication session from a specific connector. // Keyed by composite (UserID, ConnectorID), similar to OfflineSessions. // The Nonce field is a random value included in the session cookie to prevent forgery. @@ -409,6 +423,11 @@ type AuthSession struct { AbsoluteExpiry time.Time // IdleExpiry is LastActivity + ValidIfNotUsedFor, updated on every activity. IdleExpiry time.Time + + // LogoutState is set during RP-Initiated Logout before redirecting to the + // upstream provider. The callback handler reads it back to complete the flow. + // Nil when no logout is in progress. + LogoutState *LogoutState } // OfflineSessions objects are sessions pertaining to users with refresh tokens. diff --git a/web/templates/logout.html b/web/templates/logout.html new file mode 100644 index 00000000..eb2a2b74 --- /dev/null +++ b/web/templates/logout.html @@ -0,0 +1,24 @@ +{{ template "header.html" . }} + +