feat: add OIDC back-channel logout support

Implements https://openid.net/specs/openid-connect-backchannel-1_0.html

When --oidc-backchannel-logout is set (requires --session-store-type=redis),
the proxy exposes POST /oauth2/backchannel-logout. The OIDC provider (e.g.
Keycloak, Azure AD) can POST a signed logout_token to instantly revoke a
user's session server-side without a browser redirect.

Changes:
- oauthproxy.go: BackChannelLogout handler; route registered only when the
  flag is set; validates logout_token JWT per spec §2.4 (nonce absence,
  backchannel-logout event, sid claim)
- pkg/apis/sessions/interfaces.go: BackChannelSessionStore interface with
  ClearBySID(ctx, sessionID) error
- pkg/apis/sessions/session_state.go: SessionID field (sid OIDC claim)
- pkg/sessions/persistence/manager.go: ClearBySID implementation and a
  secondary sid→ticketID index written on every Save
- pkg/sessions/persistence/manager_test.go: unit tests for ClearBySID
- pkg/sessions/tests/mock_store.go: CacheSize() helper for tests
- providers/provider_data.go: BackChannelLogoutSupported field
- providers/provider_data.go: extracts sid claim into SessionState on login
- providers/providers.go: wires oidcConfig.backChannelLogoutEnabled
- pkg/apis/options/providers.go: BackChannelLogoutEnabled option
- pkg/apis/options/legacy_options.go: --oidc-backchannel-logout flag
- oauthproxy_test.go: unit tests for the BackChannelLogout handler
- docs: back-channel logout section in keycloak_oidc.md and openid_connect.md

Signed-off-by: Antonio Aranda Hernández <aaranda@hortichuelas.es>
This commit is contained in:
Antonio Aranda Hernández 2026-06-03 12:23:25 +02:00
parent 65037b086c
commit 52c7c6f975
13 changed files with 522 additions and 6 deletions

View File

@ -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** -> `<your client's id>` -> **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** -> `<your client's id>` -> **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** -> `<your client's id>` -> **Settings** -> **Logout settings**
2. Enable **Backchannel logout**
3. Set the **Backchannel logout URL** to:
```
https://<your-domain>/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://<keycloak-host>/realms/<realm>
--session-store-type=redis
--redis-connection-url=redis://localhost:6379
--oidc-backchannel-logout
```

View File

@ -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.

View File

@ -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 {

View File

@ -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)
}

View File

@ -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

View File

@ -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 {

View File

@ -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")

View File

@ -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"`

View File

@ -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)
}

View File

@ -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))
})
})
})

View File

@ -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 {

View File

@ -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

View File

@ -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
}