Skip to content

fix(introspection): compute token expiry from refreshed, not created#663

Merged
H2CK merged 1 commit into
H2CK:masterfrom
cbcoutinho:fix/introspection-expiry-uses-refreshed
Jun 20, 2026
Merged

fix(introspection): compute token expiry from refreshed, not created#663
H2CK merged 1 commit into
H2CK:masterfrom
cbcoutinho:fix/introspection-expiry-uses-refreshed

Conversation

@cbcoutinho

Copy link
Copy Markdown
Contributor

Problem

The token introspection endpoint (IntrospectionController) computes
access-token expiry as created + expire_time:

$tokenExpiryTime = $accessToken->getCreated() + $expireTime;

However, the refresh_token grant in OIDCApiController::getToken() mints a
fresh access token on the existing oc_oidc_access_tokens row and advances
refreshed, but leaves created at the original issuance time. (created is
set once in LoginRedirectorController and never updated thereafter.)

As a consequence, once the original token's lifetime has elapsed, every
subsequently refreshed access token is reported active: false by
introspection — even though it was just issued and is perfectly valid.

This breaks token renewal for OAuth resource servers that validate bearer
tokens via introspection (RFC 7662) rather than the userinfo or JWT path:
after the first token lifetime, the access token can no longer be refreshed
into a usable state, and the client must re-authenticate from scratch.

Every other expiry check in the app already uses refreshed:

  • UserInfoController::getUserInfo()getRefreshed() + expire_time
  • Listener/TokenValidationRequestListenergetRefreshed() + expire_time
  • OIDCApiController::getToken() (authorization_code) — getRefreshed() + expire_time

Introspection was the lone outlier using created.

Fix

Compute introspection expiry from refreshed + expire_time, matching the rest
of the app. At initial issuance created == refreshed, so freshly issued
(never-refreshed) tokens are unaffected; only refreshed tokens are corrected.
The expired-token log context now also includes token_refreshed.

Tests

  • Existing valid-token unit tests now set refreshed (== created, as a
    freshly issued token does).
  • New regression test testRefreshedTokenIsActiveAfterOriginalLifetime
    asserts a token whose created is past its lifetime but whose refreshed
    is recent is reported active: true with a refreshed-based exp.

Downstream context

Surfaced via the nextcloud-mcp-server OAuth integration: an MCP client (Mistral)
connected fine initially, but after the access-token lifetime expired,
automated refresh succeeded yet the freshly refreshed token was rejected
(introspection active=false → 401), leaving the connector silently unusable.


This PR was generated with the help of AI, and reviewed by a Human

🤖 Generated with Claude Code

The introspection endpoint judged access-token expiry as
`created + expire_time`. The refresh_token grant
(OIDCApiController::getToken) mints a fresh access token on the existing
row and advances `refreshed`, but leaves `created` at the original
issuance time. As a result, once the *original* token's lifetime elapsed,
every subsequently refreshed token was reported `active: false` by
introspection even though it was still valid — so resource servers that
validate via introspection (rather than the userinfo/JWT path) saw token
renewal silently fail and rejected otherwise-good bearer tokens.

Switch the expiry basis to `refreshed + expire_time`, matching
UserInfoController, TokenValidationRequestListener and OIDCApiController,
and include `token_refreshed` in the expired-token log context.

Tests: existing valid-token cases now set `refreshed` (== `created`, as a
freshly issued token does), and a new regression test asserts a token
whose `created` is past its lifetime but `refreshed` is recent is reported
active with a refreshed-based `exp`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@H2CK

H2CK commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Changes are ok for me. I will directly merge.

@H2CK H2CK merged commit 645368c into H2CK:master Jun 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants