Skip to content

Commit 76cb50b

Browse files
committed
fix(server): introspect session-bound refresh tokens against their session
533d177 added introspection's sessionAlive check and deliberately excluded refresh tokens, because they outlive the session that issued them. 155557b then let a client tie its refresh tokens to the session (refreshTokenLifetime: session), and rewrote sessionAlive to gate on exactly that flag. introspectAccessToken consults it; introspectRefreshToken never did, so a session-bound refresh token introspects active after its session has ended. Explicit logout is covered: it eagerly deletes bound clients' tokens, so the lookup fails. A session that ends by idle or absolute timeout revokes nothing eagerly, and the token introspects active until its own expiry, even though the refresh grant would refuse to redeem it. Read the token's sid from its offline-session reference, the same source the refresh grant uses, and apply the same sessionAlive check the access-token path applies: a session-bound client whose session has ended introspects inactive. A bound token whose offline session cannot be read is reported inactive, matching the grant's refusal. Standalone clients are unchanged. Signed-off-by: Sasha Mitchell <sash@ela.city>
1 parent ab64ed7 commit 76cb50b

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

server/introspection/introspection.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ type IntrospectionExtra struct {
9494
// token at all when sessions are disabled.
9595
//
9696
// Note what this does not mean: introspection reports on the token, not on the
97-
// session. A token from a session that has since ended still introspects as
98-
// active until it expires, because nothing here consults session storage.
97+
// session. For a standalone client a token from a session that has since ended
98+
// still introspects as active until it expires. For a session-bound client the
99+
// ended session takes the token with it, and introspection reports inactive.
99100
SessionID string `json:"sid,omitempty"`
100101

101102
Email string `json:"email,omitempty"`
@@ -260,6 +261,34 @@ func (h *Handler) introspectRefreshToken(ctx context.Context, token string) (*In
260261
return nil, newIntrospectInternalServerError()
261262
}
262263

264+
client, err := h.Storage.GetClient(ctx, refresh.ClientID)
265+
if err != nil {
266+
h.Logger.ErrorContext(ctx, "error while fetching client from storage", "err", err.Error())
267+
return nil, newIntrospectInternalServerError()
268+
}
269+
270+
// A refresh token's sid lives on its offline-session reference, read the same
271+
// way the refresh grant reads it: the two must agree on whether the session
272+
// the token is bound to still stands.
273+
var sessionID string
274+
offlineSessions, err := h.Storage.GetOfflineSessions(ctx, refresh.Claims.UserID, refresh.ConnectorID)
275+
if err != nil {
276+
if !errors.Is(err, storage.ErrNotFound) {
277+
h.Logger.ErrorContext(ctx, "failed to read offline session for sid", "err", err)
278+
}
279+
if client.RefreshBoundToSession() {
280+
// The grant refuses a bound token whose session cannot be read;
281+
// introspection reports it inactive.
282+
return nil, newIntrospectInactiveTokenError()
283+
}
284+
} else if ref, ok := offlineSessions.Refresh[refresh.ClientID]; ok {
285+
sessionID = ref.SessionID
286+
}
287+
288+
if !h.sessionAlive(ctx, client, subjectString, sessionID) {
289+
return nil, newIntrospectInactiveTokenError()
290+
}
291+
263292
return &Introspection{
264293
Active: true,
265294
ClientID: refresh.ClientID,

server/server_introspection_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,67 @@ func TestHandleIntrospect(t *testing.T) {
257257
})
258258
}
259259
}
260+
261+
// A session-bound client's refresh token must introspect as inactive once the
262+
// session it was issued under has ended, the same judgment the refresh grant
263+
// makes. A standalone client's token outlives the session by design.
264+
func TestHandleIntrospectRefreshTokenSessionBinding(t *testing.T) {
265+
ctx := t.Context()
266+
267+
httpServer, s := newTestServerWithSessions(t, nil)
268+
defer httpServer.Close()
269+
270+
mockTestStorage(t, s.storage)
271+
272+
// The refresh token was issued under a browser session.
273+
require.NoError(t, s.storage.UpdateOfflineSessions(ctx, "1", "test",
274+
func(old storage.OfflineSessions) (storage.OfflineSessions, error) {
275+
old.Refresh["test"].SessionID = "sid"
276+
return old, nil
277+
}))
278+
279+
// That session has since ended by timeout: the row is still stored, but both
280+
// expiries are in the past.
281+
past := time.Now().Add(-time.Hour)
282+
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
283+
ID: "sid", Secret: testSessionSecret("sid"),
284+
UserID: "1", ConnectorID: "test",
285+
CreatedAt: past, LastActivity: past,
286+
AbsoluteExpiry: past, IdleExpiry: past,
287+
}))
288+
289+
refreshToken, err := internal.Marshal(&internal.RefreshToken{RefreshId: "test", Token: "bar"})
290+
require.NoError(t, err)
291+
292+
introspect := func() string {
293+
data := url.Values{}
294+
data.Set("token", refreshToken)
295+
296+
u, err := url.Parse(s.issuerURL.String())
297+
require.NoError(t, err)
298+
u.Path = path.Join(u.Path, "token", "introspect")
299+
300+
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
301+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
302+
303+
rr := httptest.NewRecorder()
304+
s.ServeHTTP(rr, req)
305+
require.Equal(t, http.StatusOK, rr.Code)
306+
307+
result, err := io.ReadAll(rr.Body)
308+
require.NoError(t, err)
309+
return string(result)
310+
}
311+
312+
// Standalone client: the ended session changes nothing.
313+
require.Contains(t, introspect(), `"active":true`)
314+
315+
require.NoError(t, s.storage.UpdateClient(ctx, "test",
316+
func(old storage.Client) (storage.Client, error) {
317+
old.RefreshTokenLifetime = storage.RefreshTokenLifetimeSession
318+
return old, nil
319+
}))
320+
321+
// Session-bound client: the token ended with the session.
322+
require.Equal(t, "{\"active\":false}\n", introspect())
323+
}

0 commit comments

Comments
 (0)