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:
parent
65037b086c
commit
52c7c6f975
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue