diff --git a/docs/docs/configuration/providers/keycloak_oidc.md b/docs/docs/configuration/providers/keycloak_oidc.md index b29096e3..211ad52d 100644 --- a/docs/docs/configuration/providers/keycloak_oidc.md +++ b/docs/docs/configuration/providers/keycloak_oidc.md @@ -145,7 +145,39 @@ Keycloak also has the option of attaching roles to groups, please refer to the K **Tip** -To check if roles or groups are added to JWT tokens, you can preview a users token in the Keycloak console by following -these steps: **Clients** -> `` -> **Client scopes** -> **Evaluate**. -Select a _realm user_ and optional _scope parameters_ such as groups, and generate the JSON representation of an access +To check if roles or groups are added to JWT tokens, you can preview a users token in the Keycloak console by following +these steps: **Clients** -> `` -> **Client scopes** -> **Evaluate**. +Select a _realm user_ and optional _scope parameters_ such as groups, and generate the JSON representation of an access or id token to examine its contents. + +**Back-Channel Logout** + +oauth2-proxy supports [OIDC Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html), which +allows Keycloak to instantly revoke sessions server-side — no browser redirect required. This is useful when an admin +terminates a session from the Keycloak console or when a user logs out of another application sharing the same SSO +session. + +Requirements: +- `--session-store-type=redis` (cookie sessions cannot be revoked server-side) +- `--oidc-backchannel-logout` flag (or `oidcConfig.backChannelLogoutEnabled: true` in YAML config) +- Keycloak must include the `sid` claim in ID tokens (enabled by default in recent versions) + +**Keycloak configuration:** + +1. Navigate to **Clients** -> `` -> **Settings** -> **Logout settings** +2. Enable **Backchannel logout** +3. Set the **Backchannel logout URL** to: + ``` + https:///oauth2/backchannel-logout + ``` +4. Ensure **Backchannel logout session required** is enabled so Keycloak includes the `sid` claim + +**oauth2-proxy configuration:** + +``` +--provider=keycloak-oidc +--oidc-issuer-url=https:///realms/ +--session-store-type=redis +--redis-connection-url=redis://localhost:6379 +--oidc-backchannel-logout +``` diff --git a/docs/docs/configuration/providers/openid_connect.md b/docs/docs/configuration/providers/openid_connect.md index de170058..16662564 100644 --- a/docs/docs/configuration/providers/openid_connect.md +++ b/docs/docs/configuration/providers/openid_connect.md @@ -144,3 +144,26 @@ Then you can start the oauth2-proxy with `./oauth2-proxy --config /etc/example.c # http_address = "0.0.0.0:4180" ``` 7. Then you can start the oauth2-proxy with `./oauth2-proxy --config /etc/localhost.cfg` + +## Back-Channel Logout + +oauth2-proxy supports [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html). +When enabled, the identity provider can POST a signed `logout_token` to `POST /oauth2/backchannel-logout` to +instantly revoke a session server-side — no browser redirect needed. + +**Requirements:** +- `--session-store-type=redis` +- `--oidc-backchannel-logout` (or `oidcConfig.backChannelLogoutEnabled: true` in YAML) +- The provider must include the `sid` claim in ID tokens + +**How it works:** + +1. On login, the `sid` claim from the OIDC ID token is stored alongside the session in Redis. +2. When the provider sends a back-channel logout request, oauth2-proxy validates the signed `logout_token`, + extracts the `sid` claim, and immediately deletes the associated Redis session. +3. The next request from the user will find no session and redirect to login. + +If the session store does not support server-side revocation (e.g. cookie sessions), the endpoint returns +`501 Not Implemented`. + +For provider-specific setup instructions see the [Keycloak OIDC](keycloak_oidc) provider documentation. diff --git a/oauthproxy.go b/oauthproxy.go index e2357c8d..2b2bf11b 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -51,9 +51,10 @@ const ( signOutPath = "/sign_out" oauthStartPath = "/start" oauthCallbackPath = "/callback" - authOnlyPath = "/auth" - userInfoPath = "/userinfo" - staticPathPrefix = "/static/" + authOnlyPath = "/auth" + userInfoPath = "/userinfo" + backChannelLogoutPath = "/backchannel-logout" + staticPathPrefix = "/static/" idTokenPlaceholder = "{id_token}" ) @@ -353,6 +354,12 @@ func (p *OAuthProxy) buildProxySubrouter(s *mux.Router) { // The userinfo and logout endpoints needs to load sessions before handling the request s.Path(userInfoPath).Handler(p.sessionChain.ThenFunc(p.UserInfo)) s.Path(signOutPath).Handler(p.sessionChain.ThenFunc(p.SignOut)) + + // Back-channel logout: called directly by the OIDC provider, no browser session needed. + // Only registered when --oidc-backchannel-logout is enabled. + if p.provider.Data().BackChannelLogoutSupported { + s.Path(backChannelLogoutPath).HandlerFunc(p.BackChannelLogout) + } } // buildPreAuthChain constructs a chain that should process every request before @@ -785,6 +792,91 @@ func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) { http.Redirect(rw, req, redirect, http.StatusFound) } +// BackChannelLogout handles OIDC back-channel logout requests from the Identity Provider. +// https://openid.net/specs/openid-connect-backchannel-1_0.html +// +// Keycloak posts to POST /oauth2/backchannel-logout with a signed logout_token JWT. +// The handler validates the token, extracts the sid claim, and immediately deletes +// the associated Redis session — instant global logout without waiting for cookie-refresh. +// +// Only works when --session-store-type=redis is configured. +func (p *OAuthProxy) BackChannelLogout(rw http.ResponseWriter, req *http.Request) { + backChannelStore, ok := p.sessionStore.(sessionsapi.BackChannelSessionStore) + if !ok { + logger.Errorf("back-channel logout: session store does not support ClearBySID; use --session-store-type=redis") + http.Error(rw, "back-channel logout not supported by session store", http.StatusNotImplemented) + return + } + + if req.Method != http.MethodPost { + http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := req.ParseForm(); err != nil { + http.Error(rw, "bad request", http.StatusBadRequest) + return + } + + rawToken := req.FormValue("logout_token") + if rawToken == "" { + http.Error(rw, "missing logout_token", http.StatusBadRequest) + return + } + + verifier := p.provider.Data().Verifier + if verifier == nil { + logger.Errorf("back-channel logout: OIDC verifier not configured; provider must support OIDC") + http.Error(rw, "back-channel logout not supported: OIDC verifier not configured", http.StatusNotImplemented) + return + } + token, err := verifier.Verify(req.Context(), rawToken) + if err != nil { + logger.Errorf("back-channel logout: invalid logout_token: %v", err) + http.Error(rw, "invalid logout_token", http.StatusBadRequest) + return + } + + var claims struct { + SID string `json:"sid"` + Nonce string `json:"nonce"` + Events map[string]json.RawMessage `json:"events"` + } + if err := token.Claims(&claims); err != nil { + logger.Errorf("back-channel logout: failed to parse claims: %v", err) + http.Error(rw, "malformed logout_token", http.StatusBadRequest) + return + } + + // Per spec §2.4: MUST NOT contain nonce + if claims.Nonce != "" { + http.Error(rw, "invalid logout_token: must not contain nonce", http.StatusBadRequest) + return + } + + // Per spec §2.4: MUST contain the backchannel-logout event + const backChannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + if _, ok := claims.Events[backChannelLogoutEvent]; !ok { + http.Error(rw, "invalid logout_token: missing backchannel-logout event", http.StatusBadRequest) + return + } + + if claims.SID == "" { + http.Error(rw, "invalid logout_token: missing sid claim", http.StatusBadRequest) + return + } + + if err := backChannelStore.ClearBySID(req.Context(), claims.SID); err != nil { + // 200 per spec — session not found is not an error + logger.Printf("back-channel logout: session not found for sid %q (already expired?): %v", claims.SID, err) + rw.WriteHeader(http.StatusOK) + return + } + + logger.Printf("back-channel logout: session cleared for sid %q", claims.SID) + rw.WriteHeader(http.StatusOK) +} + func (p *OAuthProxy) backendLogout(rw http.ResponseWriter, req *http.Request) { session, err := p.getAuthenticatedSession(rw, req) if err != nil { diff --git a/oauthproxy_test.go b/oauthproxy_test.go index e1235a4e..acb1c7b8 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto" "encoding/base64" + "encoding/json" "fmt" "io" "net/http" @@ -23,6 +24,8 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" internaloidc "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/providers/oidc" sessionscookie "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/cookie" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/persistence" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/tests" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/upstream" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" @@ -3808,3 +3811,240 @@ func TestIdTokenPlaceholderInSignOut(t *testing.T) { assert.Equal(t, "https://my-oidc-provider.example.com/sign_out_page?id_token_hint=eYjjjjjj.vvvv.ddd&post_logout_redirect_uri=https://my-app.example.com/", newLocation) } + +// makeLogoutToken builds a minimal JWT for back-channel logout tests. +// It uses the same NoOpKeySet approach so no real signing is needed. +func makeLogoutToken(t *testing.T, claims map[string]interface{}) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + b, err := json.Marshal(claims) + require.NoError(t, err) + payload := base64.RawURLEncoding.EncodeToString(b) + return header + "." + payload + ".fakesig" +} + +// newBackChannelLogoutProxy creates a proxy wired for back-channel logout tests: +// - OIDC verifier backed by NoOpKeySet (no real signature checks) +// - persistence.Manager over a MockStore as the session store +// +// Returns the proxy and the underlying MockStore for cache-size assertions. +func newBackChannelLogoutProxy(t *testing.T) (*OAuthProxy, *tests.MockStore) { + t.Helper() + + opts := baseTestOptions() + err := validation.Validate(opts) + require.NoError(t, err) + + proxy, err := NewOAuthProxy(opts, func(string) bool { return true }) + require.NoError(t, err) + + // Build a verifier that skips signature verification (same as other JWT tests). + keyset := NoOpKeySet{} + verifier := oidc.NewVerifier("https://issuer.example.com", keyset, &oidc.Config{ + SkipExpiryCheck: true, + SkipClientIDCheck: true, + }) + internalVerifier := internaloidc.NewVerifier(verifier, internaloidc.IDTokenVerificationOptions{ + AudienceClaims: []string{"aud"}, + ClientID: clientID, + ExtraAudiences: []string{}, + }) + + // Replace the provider with one that carries the verifier. + proxy.provider = &TestProvider{ + ProviderData: &providers.ProviderData{ + Verifier: internalVerifier, + }, + ValidToken: true, + } + + // Replace the session store with a persistence.Manager backed by MockStore + // so that ClearBySID is available (implements BackChannelSessionStore). + ms := tests.NewMockStore() + proxy.sessionStore = persistence.NewManager(ms, &opts.Cookie) + + return proxy, ms +} + +const backChannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout" + +func validLogoutClaims(sid string) map[string]interface{} { + return map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "user123", + "sid": sid, + "events": map[string]interface{}{ + backChannelLogoutEvent: map[string]interface{}{}, + }, + } +} + +func TestBackChannelLogout_MethodNotAllowed(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + req := httptest.NewRequest(http.MethodGet, "/oauth2/backchannel-logout", nil) + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rw.Code) +} + +func TestBackChannelLogout_MissingToken(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "missing logout_token") +} + +func TestBackChannelLogout_InvalidToken(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + body := strings.NewReader("logout_token=not.a.valid.jwt") + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "invalid logout_token") +} + +func TestBackChannelLogout_TokenWithNonce(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + claims := validLogoutClaims("sid-abc") + claims["nonce"] = "should-not-be-here" + token := makeLogoutToken(t, claims) + + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "must not contain nonce") +} + +func TestBackChannelLogout_MissingEvent(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + claims := map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "user123", + "sid": "sid-abc", + // events claim intentionally absent + } + token := makeLogoutToken(t, claims) + + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "missing backchannel-logout event") +} + +func TestBackChannelLogout_MissingSID(t *testing.T) { + proxy, _ := newBackChannelLogoutProxy(t) + + claims := map[string]interface{}{ + "iss": "https://issuer.example.com", + "sub": "user123", + // sid intentionally absent + "events": map[string]interface{}{ + backChannelLogoutEvent: map[string]interface{}{}, + }, + } + token := makeLogoutToken(t, claims) + + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusBadRequest, rw.Code) + assert.Contains(t, rw.Body.String(), "missing sid claim") +} + +func TestBackChannelLogout_StoreDoesNotSupportClearBySID(t *testing.T) { + // Use the default cookie session store (does not implement BackChannelSessionStore). + opts := baseTestOptions() + err := validation.Validate(opts) + require.NoError(t, err) + proxy, err := NewOAuthProxy(opts, func(string) bool { return true }) + require.NoError(t, err) + + keyset := NoOpKeySet{} + verifier := oidc.NewVerifier("https://issuer.example.com", keyset, &oidc.Config{ + SkipExpiryCheck: true, + SkipClientIDCheck: true, + }) + internalVerifier := internaloidc.NewVerifier(verifier, internaloidc.IDTokenVerificationOptions{ + AudienceClaims: []string{"aud"}, + ClientID: clientID, + ExtraAudiences: []string{}, + }) + proxy.provider = &TestProvider{ + ProviderData: &providers.ProviderData{Verifier: internalVerifier}, + ValidToken: true, + } + // sessionStore is the default cookie store — does NOT implement BackChannelSessionStore. + + token := makeLogoutToken(t, validLogoutClaims("sid-abc")) + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusNotImplemented, rw.Code) +} + +func TestBackChannelLogout_SessionCleared(t *testing.T) { + proxy, ms := newBackChannelLogoutProxy(t) + + // Save a session with a known SID so back-channel logout can find it. + saveRW := httptest.NewRecorder() + saveReq := httptest.NewRequest(http.MethodGet, "/", nil) + err := proxy.sessionStore.Save(saveRW, saveReq, &sessions.SessionState{ + Email: "user@example.com", + SessionID: "test-sid-9999", + }) + require.NoError(t, err) + + // Confirm both session data and SID index were stored. + require.Equal(t, 2, ms.CacheSize()) + + token := makeLogoutToken(t, validLogoutClaims("test-sid-9999")) + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusOK, rw.Code) + assert.Equal(t, 0, ms.CacheSize(), "session and SID index should both be cleared") +} + +func TestBackChannelLogout_UnknownSIDReturns200(t *testing.T) { + // Per spec §2.8, the provider must receive a 200 even if the session is not found. + proxy, _ := newBackChannelLogoutProxy(t) + + token := makeLogoutToken(t, validLogoutClaims("unknown-sid")) + body := strings.NewReader("logout_token=" + token) + req := httptest.NewRequest(http.MethodPost, "/oauth2/backchannel-logout", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rw := httptest.NewRecorder() + proxy.BackChannelLogout(rw, req) + + assert.Equal(t, http.StatusOK, rw.Code) +} diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..04d034bc 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -561,6 +561,7 @@ type LegacyProvider struct { AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` BackendLogoutURL string `flag:"backend-logout-url" cfg:"backend_logout_url"` + OIDCBackChannelLogout bool `flag:"oidc-backchannel-logout" cfg:"oidc_backchannel_logout"` AcrValues string `flag:"acr-values" cfg:"acr_values"` JWTKey string `flag:"jwt-key" cfg:"jwt_key"` @@ -631,6 +632,7 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.StringSlice("allowed-group", []string{}, "restrict logins to members of this group (may be given multiple times)") flagSet.StringSlice("allowed-role", []string{}, "(keycloak-oidc) restrict logins to members of these roles (may be given multiple times)") flagSet.String("backend-logout-url", "", "url to perform a backend logout, {id_token} can be used as placeholder for the id_token") + flagSet.Bool("oidc-backchannel-logout", false, "enable the OIDC back-channel logout endpoint (POST /oauth2/backchannel-logout); requires --session-store-type=redis") return flagSet } @@ -731,6 +733,7 @@ func (l *LegacyProvider) convert() (Providers, error) { ExtraAudiences: l.OIDCExtraAudiences, PublicKeyFiles: l.OIDCPublicKeyFiles, EnabledSigningAlgs: l.OIDCEnabledSigningAlgs, + BackChannelLogoutEnabled: &l.OIDCBackChannelLogout, } // Support for legacy configuration option diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..52af0940 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -48,6 +48,10 @@ const ( // DefaultUseSystemTrustStore is the default value // for Provider.UseSystemTrustStore DefaultUseSystemTrustStore bool = false + + // DefaultBackChannelLogoutEnabled is the default value + // for OIDCOptions.BackChannelLogoutEnabled + DefaultBackChannelLogoutEnabled bool = false ) // OIDCAudienceClaims is the generic audience claim list used by the OIDC provider. @@ -326,6 +330,12 @@ type OIDCOptions struct { // between this list and the provider's discovered supported algorithms. // By default `RS256` is used if nothing has been discovered or specified. EnabledSigningAlgs []string `yaml:"enabledSigningAlgs,omitempty"` + // BackChannelLogoutEnabled enables the OIDC back-channel logout endpoint + // (POST /oauth2/backchannel-logout). When enabled, the identity provider can + // POST a signed logout_token to instantly revoke sessions server-side without + // a browser redirect. Requires --session-store-type=redis. + // default set to 'false' + BackChannelLogoutEnabled *bool `yaml:"backChannelLogoutEnabled,omitempty"` } type LoginGovOptions struct { diff --git a/pkg/apis/sessions/interfaces.go b/pkg/apis/sessions/interfaces.go index 97c364cf..bb8df3ab 100644 --- a/pkg/apis/sessions/interfaces.go +++ b/pkg/apis/sessions/interfaces.go @@ -15,6 +15,16 @@ type SessionStore interface { VerifyConnection(ctx context.Context) error } +// BackChannelSessionStore extends SessionStore with support for +// OIDC back-channel logout (https://openid.net/specs/openid-connect-backchannel-1_0.html). +// Persistent stores (e.g. Redis) implement this to enable instant logout +// when the provider sends a back-channel logout request. +type BackChannelSessionStore interface { + SessionStore + // ClearBySID removes the session associated with the given OIDC session ID (sid claim). + ClearBySID(ctx context.Context, sessionID string) error +} + var ErrLockNotObtained = errors.New("lock: not obtained") var ErrNotLocked = errors.New("tried to release not existing lock") diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go index 6c55e2c8..92945eda 100644 --- a/pkg/apis/sessions/session_state.go +++ b/pkg/apis/sessions/session_state.go @@ -28,6 +28,8 @@ type SessionState struct { User string `msgpack:"u,omitempty"` Groups []string `msgpack:"g,omitempty"` PreferredUsername string `msgpack:"pu,omitempty"` + // SessionID is the OIDC session ID (sid claim) used for back-channel logout. + SessionID string `msgpack:"si,omitempty"` // Additional claims AdditionalClaims map[string]interface{} `msgpack:"ac,omitempty"` diff --git a/pkg/sessions/persistence/manager.go b/pkg/sessions/persistence/manager.go index 9652f015..10ebda38 100644 --- a/pkg/sessions/persistence/manager.go +++ b/pkg/sessions/persistence/manager.go @@ -8,6 +8,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" ) // Manager wraps a Store and handles the implementation details of the @@ -26,6 +27,33 @@ func NewManager(store Store, cookieOpts *options.Cookie) *Manager { } } +// sidIndexKey returns the Redis key that maps an OIDC session ID (sid claim) +// to a session ticket ID, enabling back-channel logout lookups. +func (m *Manager) sidIndexKey(sessionID string) string { + return m.Options.Name + "-sid-" + sessionID +} + +// ClearBySID removes the session identified by the given OIDC session ID. +// Called during OIDC back-channel logout. Implements BackChannelSessionStore. +func (m *Manager) ClearBySID(ctx context.Context, sessionID string) error { + sidKey := m.sidIndexKey(sessionID) + + ticketIDBytes, err := m.Store.Load(ctx, sidKey) + if err != nil { + return fmt.Errorf("no session found for sid %q: %w", sessionID, err) + } + + if err := m.Store.Clear(ctx, string(ticketIDBytes)); err != nil { + return fmt.Errorf("failed to clear session for sid %q: %w", sessionID, err) + } + + if err := m.Store.Clear(ctx, sidKey); err != nil { + logger.Errorf("failed to clear SID index for %q (non-fatal): %v", sessionID, err) + } + + return nil +} + // Save saves a session in a persistent Store. Save will generate (or reuse an // existing) ticket which manages unique per session encryption & retrieval // from the persistent data store. @@ -49,6 +77,14 @@ func (m *Manager) Save(rw http.ResponseWriter, req *http.Request, s *sessions.Se return err } + // Secondary index: sid → ticketID, for back-channel logout + if s.SessionID != "" { + sidKey := m.sidIndexKey(s.SessionID) + if err := m.Store.Save(req.Context(), sidKey, []byte(tckt.id), m.Options.Expire); err != nil { + logger.Errorf("failed to save SID index for back-channel logout: %v", err) + } + } + return tckt.setCookie(rw, req, s) } diff --git a/pkg/sessions/persistence/manager_test.go b/pkg/sessions/persistence/manager_test.go index 3e67daa3..336e8b7e 100644 --- a/pkg/sessions/persistence/manager_test.go +++ b/pkg/sessions/persistence/manager_test.go @@ -1,12 +1,15 @@ package persistence import ( + "context" + "net/http/httptest" "time" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/tests" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) var _ = Describe("Persistence Manager Tests", func() { @@ -22,4 +25,56 @@ var _ = Describe("Persistence Manager Tests", func() { ms.FastForward(d) return nil }) + + Describe("ClearBySID", func() { + var ( + manager *Manager + cookieOpts *options.Cookie + ) + + BeforeEach(func() { + ms = tests.NewMockStore() + cookieOpts = &options.Cookie{ + Name: "_oauth2_proxy", + Expire: time.Hour, + } + manager = NewManager(ms, cookieOpts) + }) + + It("clears the session and SID index when a valid SID is given", func() { + rw := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + session := &sessionsapi.SessionState{ + Email: "user@example.com", + SessionID: "test-sid-1234", + } + Expect(manager.Save(rw, req, session)).To(Succeed()) + // After save: session data + SID index = 2 entries + Expect(ms.CacheSize()).To(Equal(2)) + + Expect(manager.ClearBySID(context.Background(), "test-sid-1234")).To(Succeed()) + // Both entries should be gone + Expect(ms.CacheSize()).To(Equal(0)) + }) + + It("returns an error when the SID is not found", func() { + err := manager.ClearBySID(context.Background(), "nonexistent-sid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no session found for sid")) + }) + + It("does not write a SID index when SessionID is empty", func() { + rw := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + session := &sessionsapi.SessionState{ + Email: "user@example.com", + // SessionID intentionally empty + } + Expect(manager.Save(rw, req, session)).To(Succeed()) + // Only session data, no SID index + Expect(ms.CacheSize()).To(Equal(1)) + }) + }) }) diff --git a/pkg/sessions/tests/mock_store.go b/pkg/sessions/tests/mock_store.go index c82d8c08..eb04da4a 100644 --- a/pkg/sessions/tests/mock_store.go +++ b/pkg/sessions/tests/mock_store.go @@ -69,6 +69,11 @@ func (s *MockStore) VerifyConnection(_ context.Context) error { return nil } +// CacheSize returns the number of entries currently in the cache +func (s *MockStore) CacheSize() int { + return len(s.cache) +} + // FastForward simulates the flow of time to test expirations func (s *MockStore) FastForward(duration time.Duration) { for _, mockLock := range s.lockCache { diff --git a/providers/provider_data.go b/providers/provider_data.go index 80bd77ae..cc03f97e 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -76,6 +76,11 @@ type ProviderData struct { loginURLParameterOverrides map[string]*regexp.Regexp BackendLogoutURL string + + // BackChannelLogoutSupported indicates that the OIDC back-channel logout + // endpoint is enabled. Set via --oidc-backchannel-logout or the YAML option + // oidcConfig.backChannelLogoutEnabled. Requires Redis session storage. + BackChannelLogoutSupported bool } // Data returns the ProviderData @@ -281,6 +286,8 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (* {p.GroupsClaim, &ss.Groups}, // TODO (@NickMeves) Deprecate for dynamic claim to session mapping {"preferred_username", &ss.PreferredUsername}, + // sid is the OIDC session ID used for back-channel logout + {"sid", &ss.SessionID}, } { if _, err := extractor.GetClaimInto(c.claim, c.dst); err != nil { return nil, err diff --git a/providers/providers.go b/providers/providers.go index f87d26a2..3164b555 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -171,6 +171,7 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, p.setAllowedGroups(providerConfig.AllowedGroups) p.BackendLogoutURL = providerConfig.BackendLogoutURL + p.BackChannelLogoutSupported = ptr.Deref(providerConfig.OIDCConfig.BackChannelLogoutEnabled, options.DefaultBackChannelLogoutEnabled) return p, nil }