Skip to content

Commit 225f7c8

Browse files
committed
feat(server): sid claim, back-channel logout and scoped refresh revocation
Completes RP-initiated logout: - Redirect to post_logout_redirect_uri instead of rendering a page with a link to it. RP-Initiated Logout section 4 asks for a redirect, and an RP like oauth2-proxy just strands the user on a dex page without it. - Take the session from the cookie rather than id_token_hint. The hint is accepted past its expiry, so trusting its subject meant any ID token that ever leaked was a permanent licence to log that user out. It now only names the requesting client and skips the confirmation prompt when it matches the current session. - Emit a sid claim, derived as base64url(sha256(session nonce)). The nonce authenticates the session cookie and must not be published as-is; hashing keeps sid stable and unique per session with no storage change. - Notify relying parties over OIDC Back-Channel Logout. Clients register a backchannelLogoutURI; delivery is concurrent, time-boxed and best effort. - Revoke only the requesting client's refresh token. Revoking every token the user owned meant signing out of a web app destroyed the same user's kubectl credentials. The administrative gRPC paths keep the unscoped behaviour via RevokeAll, which is what an operator asks for. Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
1 parent 8fb20d1 commit 225f7c8

33 files changed

Lines changed: 1306 additions & 212 deletions

server/apiserver/identities.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ func (d dexAPI) DeleteUserIdentity(ctx context.Context, req *api.DeleteUserIdent
116116
return nil, fmt.Errorf("purge auth session: %v", err)
117117
}
118118

119-
// Cascade: revoke all refresh tokens (best-effort, consistent with logout flow).
119+
// Cascade: revoke all refresh tokens (best-effort). A purge has to take every
120+
// credential with it, so the unscoped revoke is the right one here.
120121
d.revokeUserRefreshTokens(ctx, req.UserId, req.ConnectorId)
121122

122123
// Cascade: delete offline sessions.

server/apiserver/refresh.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,21 @@ func (d dexAPI) RevokeRefresh(ctx context.Context, req *api.RevokeRefreshReq) (*
9696
// revokeUserRefreshTokens revokes all refresh tokens for a user/connector pair
9797
// and cleans up offline session references. Errors are logged but not returned
9898
// (best-effort).
99+
//
100+
// This is deliberately broader than what RP-initiated logout does. That flow revokes
101+
// only the client that asked for it, because it has a requesting client to scope to
102+
// and no mandate to touch anyone else's credentials (see revokeRequestingClient in
103+
// server/logout). An administrative call has neither: there is no client_id in the
104+
// request, and ending access is the entire point of the operation. Callers that want
105+
// one client's token gone use RevokeRefresh.
106+
//
107+
// TODO(nabokihms): notify relying parties over back-channel logout here too. Today
108+
// only the HTTP logout endpoint fans out logout tokens, so an administrator who
109+
// terminates a session ends it in dex and revokes the refresh tokens, but every RP
110+
// keeps serving the user from its own cookie until that cookie expires or its next
111+
// refresh fails. Fixing it means lifting the notifier out of logout.Handler into
112+
// something the apiserver can hold, and calling it before the session is deleted in
113+
// DeleteAuthSession, terminateSessions and DeleteUserIdentity.
99114
func (d dexAPI) revokeUserRefreshTokens(ctx context.Context, userID, connectorID string) {
100-
d.refresh.Revoke(ctx, userID, connectorID)
115+
d.refresh.RevokeAll(ctx, userID, connectorID)
101116
}

server/apiserver/sessions.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ func (d dexAPI) DeleteAuthSession(ctx context.Context, req *api.DeleteAuthSessio
100100
return nil, errors.New("no connector_id supplied")
101101
}
102102

103-
// Revoke refresh tokens (best-effort, consistent with logout flow).
103+
// Revoke every refresh token the user holds on this connector, not just one
104+
// client's. See revokeUserRefreshTokens for why the administrative path is
105+
// deliberately broader than RP-initiated logout.
104106
d.revokeUserRefreshTokens(ctx, req.UserId, req.ConnectorId)
105107

106108
if err := d.s.DeleteAuthSession(ctx, req.UserId, req.ConnectorId); err != nil {

server/authflow/handler_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func newTestHandler(t *testing.T, updateConfig func(c *testFlowConfig)) (*httpte
104104

105105
now := func() time.Time { return time.Now() }
106106
conns := connectors.NewCache(store, testResolveConnector)
107-
issuer := tokens.NewIssuer(store, sig, *issuerURL, 24*time.Hour, now, logger)
107+
issuer := tokens.NewIssuer(store, sig, *issuerURL, 24*time.Hour, now, logger, nil)
108108

109109
tc := testFlowConfig{
110110
Handler: Handler{

server/discovery/discovery.go

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,27 @@ func (h *Handler) Mount(m router.Mux) {
5050

5151
// Document is the OIDC discovery document.
5252
type Document struct {
53-
Issuer string `json:"issuer"`
54-
Auth string `json:"authorization_endpoint"`
55-
Token string `json:"token_endpoint"`
56-
Keys string `json:"jwks_uri"`
57-
UserInfo string `json:"userinfo_endpoint"`
58-
DeviceEndpoint string `json:"device_authorization_endpoint"`
59-
Introspect string `json:"introspection_endpoint"`
60-
EndSession string `json:"end_session_endpoint,omitempty"`
61-
GrantTypes []string `json:"grant_types_supported"`
62-
ResponseTypes []string `json:"response_types_supported"`
63-
Subjects []string `json:"subject_types_supported"`
64-
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
65-
CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
66-
Scopes []string `json:"scopes_supported"`
67-
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
68-
Claims []string `json:"claims_supported"`
53+
Issuer string `json:"issuer"`
54+
Auth string `json:"authorization_endpoint"`
55+
Token string `json:"token_endpoint"`
56+
Keys string `json:"jwks_uri"`
57+
UserInfo string `json:"userinfo_endpoint"`
58+
DeviceEndpoint string `json:"device_authorization_endpoint"`
59+
Introspect string `json:"introspection_endpoint"`
60+
EndSession string `json:"end_session_endpoint,omitempty"`
61+
// BackchannelLogout and BackchannelLogoutSession advertise OIDC Back-Channel
62+
// Logout 1.0. Both are omitted rather than sent as false when sessions are off,
63+
// matching how end_session_endpoint disappears with them.
64+
BackchannelLogout bool `json:"backchannel_logout_supported,omitempty"`
65+
BackchannelLogoutSession bool `json:"backchannel_logout_session_supported,omitempty"`
66+
GrantTypes []string `json:"grant_types_supported"`
67+
ResponseTypes []string `json:"response_types_supported"`
68+
Subjects []string `json:"subject_types_supported"`
69+
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
70+
CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
71+
Scopes []string `json:"scopes_supported"`
72+
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
73+
Claims []string `json:"claims_supported"`
6974
}
7075

7176
// Keys serves the JSON Web Key Set.
@@ -142,7 +147,8 @@ func (h *Handler) Construct(ctx context.Context) Document {
142147
AuthMethods: []string{"client_secret_basic", "client_secret_post"},
143148
Claims: []string{
144149
"iss", "sub", "aud", "iat", "exp", "email", "email_verified",
145-
"locale", "name", "preferred_username", "at_hash",
150+
"locale", "name", "preferred_username", "at_hash", "groups",
151+
"federated_claims",
146152
},
147153
}
148154

@@ -163,6 +169,11 @@ func (h *Handler) Construct(ctx context.Context) Document {
163169

164170
if h.SessionsEnabled {
165171
d.EndSession = h.IssuerURL.AbsURL("/logout")
172+
d.BackchannelLogout = true
173+
// Dex always puts a sid in its logout tokens, so clients never need to set
174+
// backchannel_logout_session_required to get one.
175+
d.BackchannelLogoutSession = true
176+
d.Claims = append(d.Claims, "sid")
166177
}
167178

168179
return d

server/introspection/introspection_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func testAccessToken(t *testing.T) string {
4646
issURL, err := url.Parse(testIssuer)
4747
require.NoError(t, err)
4848

49-
issuer := tokens.NewIssuer(memory.New(logger), sig, *issURL, time.Hour, time.Now, logger)
49+
issuer := tokens.NewIssuer(memory.New(logger), sig, *issURL, time.Hour, time.Now, logger, nil)
5050
token, _, err := issuer.SignIDToken(ctx, tokens.Authorization{
5151
Client: storage.Client{ID: "test"},
5252
Claims: storage.Claims{UserID: "1", Username: "jane"},

server/logout/backchannel.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package logout
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"sync"
11+
"time"
12+
13+
"github.com/google/uuid"
14+
15+
"github.com/dexidp/dex/server/internal"
16+
"github.com/dexidp/dex/server/session"
17+
"github.com/dexidp/dex/storage"
18+
)
19+
20+
const (
21+
// backchannelLogoutEvent is the event identifier a logout token must carry, per
22+
// OIDC Back-Channel Logout 1.0 §2.4.
23+
backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
24+
25+
// backchannelTokenLifetime bounds the replay window for a logout token. The spec
26+
// recommends no more than two minutes.
27+
backchannelTokenLifetime = 2 * time.Minute
28+
29+
// backchannelTimeout caps how long dex waits on one RP. Logout must not hang on a
30+
// wedged relying party.
31+
backchannelTimeout = 5 * time.Second
32+
)
33+
34+
// logoutTokenClaims is the JWT dex POSTs to a relying party's backchannel_logout_uri.
35+
//
36+
// Note the absences: there is no "nonce" (the spec forbids it, to keep a logout token
37+
// from being mistaken for an ID token) and no "events" payload beyond an empty object.
38+
type logoutTokenClaims struct {
39+
Issuer string `json:"iss"`
40+
Subject string `json:"sub"`
41+
Audience string `json:"aud"`
42+
IssuedAt int64 `json:"iat"`
43+
Expiry int64 `json:"exp"`
44+
JWTID string `json:"jti"`
45+
SessionID string `json:"sid"`
46+
Events map[string]json.RawMessage `json:"events"`
47+
}
48+
49+
// notifyBackchannel tells every relying party in the session that it is over.
50+
//
51+
// Delivery is best-effort and fire-and-forget: RP-Initiated Logout treats notifying
52+
// other RPs as a courtesy, and a relying party that is down must not be able to block
53+
// or fail the user's logout. Failures are logged and dropped.
54+
//
55+
// ponytail: no retries and no durable queue. An RP that is unreachable for these few
56+
// seconds keeps its session until it expires on its own. If that becomes a real
57+
// problem, the upgrade path is to persist pending notifications and drain them from
58+
// the garbage collector, not to make the user wait here.
59+
func (h *Handler) notifyBackchannel(ctx context.Context, authSession *storage.AuthSession) {
60+
if len(authSession.ClientStates) == 0 {
61+
return
62+
}
63+
64+
subject, err := internal.Marshal(&internal.IDTokenSubject{
65+
UserId: authSession.UserID,
66+
ConnId: authSession.ConnectorID,
67+
})
68+
if err != nil {
69+
h.Logger.ErrorContext(ctx, "logout: failed to marshal backchannel subject", "err", err)
70+
return
71+
}
72+
73+
sid := session.SessionID(authSession.Nonce)
74+
75+
// The request context dies the moment we redirect the browser, so deliveries get
76+
// their own bounded context rather than being canceled halfway through.
77+
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backchannelTimeout)
78+
defer cancel()
79+
80+
var wg sync.WaitGroup
81+
for clientID := range authSession.ClientStates {
82+
client, err := h.Storage.GetClient(ctx, clientID)
83+
if err != nil {
84+
h.Logger.DebugContext(ctx, "logout: backchannel skipped, client not found",
85+
"client_id", clientID, "err", err)
86+
continue
87+
}
88+
if client.BackchannelLogoutURI == "" {
89+
continue
90+
}
91+
92+
wg.Go(func() { h.deliverLogoutToken(ctx, client, subject, sid) })
93+
}
94+
wg.Wait()
95+
}
96+
97+
// deliverLogoutToken mints a logout token for one client and POSTs it.
98+
func (h *Handler) deliverLogoutToken(ctx context.Context, client storage.Client, subject, sid string) {
99+
token, err := h.signLogoutToken(ctx, client.ID, subject, sid)
100+
if err != nil {
101+
h.Logger.ErrorContext(ctx, "logout: failed to sign logout token",
102+
"client_id", client.ID, "err", err)
103+
return
104+
}
105+
106+
body := url.Values{"logout_token": {token}}.Encode()
107+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BackchannelLogoutURI, strings.NewReader(body))
108+
if err != nil {
109+
h.Logger.ErrorContext(ctx, "logout: failed to build backchannel request",
110+
"client_id", client.ID, "err", err)
111+
return
112+
}
113+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
114+
req.Header.Set("Cache-Control", "no-cache, no-store")
115+
116+
resp, err := h.backchannelClient().Do(req)
117+
if err != nil {
118+
h.Logger.WarnContext(ctx, "logout: backchannel delivery failed",
119+
"client_id", client.ID, "uri", client.BackchannelLogoutURI, "err", err)
120+
return
121+
}
122+
defer resp.Body.Close()
123+
124+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
125+
h.Logger.WarnContext(ctx, "logout: backchannel delivery rejected",
126+
"client_id", client.ID, "uri", client.BackchannelLogoutURI, "status", resp.StatusCode)
127+
return
128+
}
129+
130+
h.Logger.DebugContext(ctx, "logout: backchannel delivered", "client_id", client.ID)
131+
}
132+
133+
// signLogoutToken builds and signs the logout token for one audience.
134+
func (h *Handler) signLogoutToken(ctx context.Context, clientID, subject, sid string) (string, error) {
135+
now := time.Now()
136+
if h.Now != nil {
137+
now = h.Now()
138+
}
139+
140+
claims := logoutTokenClaims{
141+
Issuer: h.IssuerURL.String(),
142+
Subject: subject,
143+
Audience: clientID,
144+
IssuedAt: now.Unix(),
145+
Expiry: now.Add(backchannelTokenLifetime).Unix(),
146+
JWTID: uuid.New().String(),
147+
SessionID: sid,
148+
Events: map[string]json.RawMessage{backchannelLogoutEvent: json.RawMessage(`{}`)},
149+
}
150+
151+
payload, err := json.Marshal(claims)
152+
if err != nil {
153+
return "", fmt.Errorf("marshal logout token: %w", err)
154+
}
155+
156+
token, err := h.Signer.Sign(ctx, payload)
157+
if err != nil {
158+
return "", fmt.Errorf("sign logout token: %w", err)
159+
}
160+
return token, nil
161+
}
162+
163+
// backchannelClient returns the HTTP client used for delivery, defaulting to one with
164+
// no redirect following: a logout token must reach the URI the client registered, not
165+
// wherever that URI happens to point today.
166+
func (h *Handler) backchannelClient() *http.Client {
167+
if h.HTTPClient != nil {
168+
return h.HTTPClient
169+
}
170+
return &http.Client{
171+
CheckRedirect: func(*http.Request, []*http.Request) error {
172+
return http.ErrUseLastResponse
173+
},
174+
}
175+
}

0 commit comments

Comments
 (0)