Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Security] Stamp auth_time from the OIDC ID token claim - #66015

Open
nicolas-grekas wants to merge 1 commit into
is-authenticated-recentlyfrom
is-authenticated-recently-oidc
Open

[Security] Stamp auth_time from the OIDC ID token claim#66015
nicolas-grekas wants to merge 1 commit into
is-authenticated-recentlyfrom
is-authenticated-recently-oidc

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Member
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Part 2 of the sudo mode stack, on top of #66014.

OIDC already knows when the user really authenticated: the ID token carries an auth_time claim, and OidcIdToken validates it against max_age. Sudo mode was ignoring it, so a silent SSO login from a session the provider opened hours ago counted as a fresh authentication.

The ordering trap, which is the actual content of this PR

AuthenticatorManager calls createToken() at line 200 and dispatches INTERACTIVE_LOGIN at line 236. OidcLoginAuthenticator::isInteractive() returns true, so AuthenticationTimeListener already stamps every OIDC login with the current time. Setting the attribute in createToken() and changing nothing else is therefore a silent no-op: the listener overwrites it milliseconds later, and a unit test on createToken() alone passes while the feature does nothing.

So the listener becomes a default rather than an assignment:

if (!$token->hasAttribute(AuthenticatedVoter::AUTH_TIME_ATTRIBUTE)) {
    $token->setAttribute(AuthenticatedVoter::AUTH_TIME_ATTRIBUTE, ...);
}

That establishes the contract worth having anyway: an authenticator that knows the real authentication time stamps it, and the listener only fills the gap. It is safe because every token reaching INTERACTIVE_LOGIN is freshly built by createToken(); SwitchUserToken neither copies the original token's attributes nor dispatches that event.

AuthenticationTimeListenerTest::testItDoesNotOverwriteATimeTheAuthenticatorAlreadyRecorded() pins it. I verified it bites by removing the guard: it fails.

Two details that are not optional

  • Read $idTokenClaims, not $claims. Under the default user_data_source: userinfo, $claims is the UserInfo response, which does not carry auth_time.
  • Clamp with min($authTime, now). The claim is only validated when max_age is requested, so without it a provider whose clock runs ahead would date the stamp in the future and extend the window IS_AUTHENTICATED_RECENTLY grants beyond the configured lifetime. Non-numeric values are discarded rather than trusted, since time() - '<string>' is a TypeError inside the voter.

Fallback

When the provider sends no auth_time, the attribute is left unset and the listener stamps the login instant, which keeps OIDC consistent with form login, JSON login and login links. The honest caveat, for the docs: if the IdP performed silent SSO and sent no claim, "now" overstates the freshness, and the answer is to configure max_age, which makes the claim mandatory and validated.

A silent token refresh does not bump the stamp: OidcTokenRefresher writes only the four oidc_* attributes and dispatches no event, which matches OIDC Core 12.2, where a refreshed ID token's auth_time still reports the original authentication.

AuthenticatorManager calls createToken() before it dispatches INTERACTIVE_LOGIN, so
AuthenticationTimeListener now only fills the attribute when no authenticator recorded
it. OidcLoginAuthenticator carries the "auth_time" claim through the passport and
clamps it to the present, so a silent SSO login is not mistaken for a fresh one.
@nicolas-grekas
nicolas-grekas force-pushed the is-authenticated-recently-oidc branch from 1d5db6f to e283875 Compare September 13, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants