Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved validation, state-correlation, time-provider, and front-channel token-check issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds OIDC auth_time validation against the effective max_age, with protected correlation, flow coverage, compatibility configuration, and documentation.
Changes:
- Validates front- and back-channel ID tokens with clock-skew handling.
- Adds comprehensive regression and boundary tests.
- Documents PAR behavior and the compatibility switch.
File summaries
| File | Summary |
|---|---|
src/Security/Authentication/test/OpenIdConnect/OpenIdConnectMaxAgeTests.cs |
Adds comprehensive behavioral and regression coverage. |
src/Security/Authentication/OpenIdConnect/src/PACKAGE.md |
Documents validation, compatibility, and PAR behavior. |
src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs |
Updates MaxAge documentation. |
src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs |
Implements correlation and token validation. Findings: reject negative correlated values (moderate, 2 votes), clear stale state (moderate, 2 votes), use the normalized time provider (moderate, 1 vote), and gate front-channel validation on jwt (critical, 1 vote). |
src/Security/Authentication/OpenIdConnect/src/OpenIdConnectChallengeProperties.cs |
Documents per-challenge precedence and validation. |
Review details
Suppressed comments (1)
src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs:1013
Options.TimeProvideris nullable and is normally left unset;AuthenticationHandlerapplies theTimeProvider.Systemfallback only to its inheritedTimeProviderproperty. Therefore a normal application that enablesMaxAgereaches this line withOptions.TimeProvider == nulland throwsNullReferenceExceptioninstead of validatingauth_time(the new tests hide this by always assigning the option). Use the handler's normalizedTimeProviderproperty.
var now = Options.TimeProvider!.GetUtcNow();
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| if (!string.IsNullOrEmpty(authorizationResponse.IdToken)) | ||
| { | ||
| ValidateAuthTime(jwt, maxAge); | ||
| } |
There was a problem hiding this comment.
👍 I generally favor treating event handler mutations as authoritative, but clearing the serialized ProtocolMessage.IdToken doesn’t discard the retained SecurityToken or principal. I wouldn’t expect it to suppress the max_age check on that token.
| if (!string.IsNullOrEmpty(message.MaxAge)) | ||
| { | ||
| properties.Items[MaxAgeProperty] = message.MaxAge; | ||
| } |
| if (!long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var maxAge)) | ||
| { | ||
| throw new SecurityTokenValidationException("The correlated max_age value is invalid."); | ||
| } | ||
|
|
||
| return maxAge; | ||
| } |
There was a problem hiding this comment.
NumberStyles.None already rejects the minus sign, so this parse doesn’t accept negative values.
| if (!string.IsNullOrEmpty(authorizationResponse.IdToken)) | ||
| { | ||
| ValidateAuthTime(jwt, maxAge); | ||
| } |
There was a problem hiding this comment.
👍 I generally favor treating event handler mutations as authoritative, but clearing the serialized ProtocolMessage.IdToken doesn’t discard the retained SecurityToken or principal. I wouldn’t expect it to suppress the max_age check on that token.
| throw new SecurityTokenValidationException("The auth_time claim must be a valid integral NumericDate when max_age is requested."); | ||
| } | ||
|
|
||
| var now = Options.TimeProvider!.GetUtcNow(); |
There was a problem hiding this comment.
Nit:
| var now = Options.TimeProvider!.GetUtcNow(); | |
| var now = TimeProvider.GetUtcNow(); |
| private static bool TryReadNumericDate(object? value, out long numericDate) | ||
| { | ||
| if (value is long longValue) | ||
| { | ||
| numericDate = longValue; | ||
| } | ||
| else if (value is int intValue) | ||
| { | ||
| numericDate = intValue; | ||
| } | ||
| else if (value is uint uintValue) | ||
| { | ||
| numericDate = uintValue; | ||
| } | ||
| else if (value is short shortValue) | ||
| { | ||
| numericDate = shortValue; | ||
| } | ||
| else if (value is ushort ushortValue) | ||
| { | ||
| numericDate = ushortValue; | ||
| } | ||
| else if (value is byte byteValue) | ||
| { | ||
| numericDate = byteValue; | ||
| } | ||
| else if (value is sbyte sbyteValue) | ||
| { | ||
| numericDate = sbyteValue; | ||
| } | ||
| else if (value is ulong ulongValue && ulongValue <= long.MaxValue) | ||
| { | ||
| numericDate = (long)ulongValue; | ||
| } | ||
| else if (value is JsonElement { ValueKind: JsonValueKind.Number } element && element.TryGetInt64(out var elementValue)) | ||
| { | ||
| numericDate = elementValue; | ||
| } | ||
| else | ||
| { | ||
| numericDate = 0; | ||
| return false; | ||
| } | ||
|
|
||
| if (numericDate < 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| _ = DateTimeOffset.FromUnixTimeSeconds(numericDate); | ||
| return true; | ||
| } | ||
| catch (ArgumentOutOfRangeException) | ||
| { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
| private static bool TryReadNumericDate(object? value, out long numericDate) | |
| { | |
| if (value is long longValue) | |
| { | |
| numericDate = longValue; | |
| } | |
| else if (value is int intValue) | |
| { | |
| numericDate = intValue; | |
| } | |
| else if (value is uint uintValue) | |
| { | |
| numericDate = uintValue; | |
| } | |
| else if (value is short shortValue) | |
| { | |
| numericDate = shortValue; | |
| } | |
| else if (value is ushort ushortValue) | |
| { | |
| numericDate = ushortValue; | |
| } | |
| else if (value is byte byteValue) | |
| { | |
| numericDate = byteValue; | |
| } | |
| else if (value is sbyte sbyteValue) | |
| { | |
| numericDate = sbyteValue; | |
| } | |
| else if (value is ulong ulongValue && ulongValue <= long.MaxValue) | |
| { | |
| numericDate = (long)ulongValue; | |
| } | |
| else if (value is JsonElement { ValueKind: JsonValueKind.Number } element && element.TryGetInt64(out var elementValue)) | |
| { | |
| numericDate = elementValue; | |
| } | |
| else | |
| { | |
| numericDate = 0; | |
| return false; | |
| } | |
| if (numericDate < 0) | |
| { | |
| return false; | |
| } | |
| try | |
| { | |
| _ = DateTimeOffset.FromUnixTimeSeconds(numericDate); | |
| return true; | |
| } | |
| catch (ArgumentOutOfRangeException) | |
| { | |
| return false; | |
| } | |
| } | |
| private static bool TryReadNumericDate(object? value, out long numericDate) | |
| { | |
| numericDate = value switch | |
| { | |
| int number => number, | |
| long number => number, | |
| _ => -1, | |
| }; | |
| return numericDate >= 0 && | |
| numericDate <= DateTimeOffset.MaxValue.ToUnixTimeSeconds(); | |
| } |
Looking at IdentityModel's ReadNumber implementation, it tries TryGetInt32 first followed by TryGetInt64 before any of the numeric types the above code checks against. I think int and long ought to be enough to cover any deserialized integer-form auth_time values within the supported date range.
| if (!long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var maxAge)) | ||
| { | ||
| throw new SecurityTokenValidationException("The correlated max_age value is invalid."); | ||
| } | ||
|
|
||
| return maxAge; | ||
| } |
There was a problem hiding this comment.
NumberStyles.None already rejects the minus sign, so this parse doesn’t accept negative values.
| true); | ||
| ``` | ||
|
|
||
| The switch defaults to `false` and is intended only as a temporary provider-compatibility escape hatch. Applications that do not set `MaxAge` are unaffected. When pushed authorization and validation are used, set or modify `max_age` in `OnRedirectToIdentityProvider`; changing it later in `OnPushAuthorization` is rejected because the handler must correlate the pushed value with the authorization response. |
There was a problem hiding this comment.
Applications that do not set
MaxAgeare unaffected.
This is mostly true, but there are some other ways you might be impacted (e.g. max_age through AdditionalAuthorizationParameters or OnRedirectToIdentityProvider).
| The switch defaults to `false` and is intended only as a temporary provider-compatibility escape hatch. Applications that do not set `MaxAge` are unaffected. When pushed authorization and validation are used, set or modify `max_age` in `OnRedirectToIdentityProvider`; changing it later in `OnPushAuthorization` is rejected because the handler must correlate the pushed value with the authorization response. | |
| The switch defaults to `false` and is intended only as a temporary provider-compatibility escape hatch. Applications that do not configure a `max_age` are unaffected. The most common way applications configure this is through `OpenIdConnectOptions.MaxAge`. | |
| When pushed authorization and validation are used, set or modify `max_age` in `OnRedirectToIdentityProvider`; changing it later in `OnPushAuthorization` is rejected unless pushing is skipped, because the handler has already correlated the value with the authorization response. |
It might also be worth tweaking the announcement too.
Fixes #67462
Overview
OpenIdConnectHandleralready sent an effectivemax_agefromOpenIdConnectChallengeProperties.MaxAgeorOpenIdConnectOptions.MaxAge, but accepted an ID token without checking its signedauth_time. A faithful pre-fix end-to-end reproduction sentmax_age=300with noauth_time; authentication still completed with302 Foundinstead of failing.This change automatically validates
auth_timewhenever an effectiveMaxAgeis sent. It adds no public API. A process-wide AppContext compatibility switch,Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation, is off by default; explicitly setting it totruerestores the legacy request-only behavior for temporarily incompatible providers.Design
The exact final ordinary authorization-request value is captured after
OnRedirectToIdentityProviderand stored in protectedAuthenticationPropertiesstate. This preserves per-challenge-over-options precedence while preventing concurrent challenges from sharing mutable configuration:The callback removes that internal item before ticket construction. It parses the correlated value as a nonnegative integer and runs validation only after existing signature, issuer, audience, lifetime, nonce, and OIDC protocol validation. Front-channel ID tokens are checked after
ValidateAuthenticationResponse; token-endpoint ID tokens are checked afterValidateTokenResponse, so hybrid flows validate both tokens. The code-flow check uses the currentjwtafterOnTokenValidated, matching the token used by protocol validation when an application replacesTokenValidatedContext.SecurityToken:auth_timemust be an integral, nonnegative OIDC NumericDate representable byDateTimeOffset. UsingOptions.TimeProvider.GetUtcNow()andTokenValidationParameters.ClockSkew, validation rejects:auth_time > now + skewnow - auth_time > max_age + skewExact boundaries and values within skew are accepted. Missing, string-valued, fractional, negative, overflowed, stale, or excessively future values fail through the existing authentication-failed and remote-failure paths. No automatic rechallenge occurs, and UserInfo retrieval,
SaveTokens, ticket creation, and sign-in do not continue.For PAR, changing
max_ageinOnPushAuthorizationis rejected only when validation is active and the pushed request is actually used, because protected state has already correlated the ordinary redirect value. Mutation remains allowed when push is skipped or the compatibility switch restores legacy request-only behavior. Applications should make effective request changes inOnRedirectToIdentityProvider.Implementation
OpenIdConnectChallengeProperties.MaxAgeoverOpenIdConnectOptions.MaxAgeprecedence and outgoing serialization.max_agethrough protected per-transaction state.TokenHandler/JsonWebTokenpath and legacySecurityTokenValidator/JwtSecurityTokenpath.id_token,id_token token), and hybrid (code token,code id_token,code id_token token) paths.MaxAgeis unset.MaxAgecontract, clock-skew behavior, PAR constraint, compatibility switch, and trust boundary in XML docs andPACKAGE.md.The compatibility switch can be enabled in runtime configuration:
{ "runtimeOptions": { "configProperties": { "Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation": true } } }or before authentication starts:
The switch defaults to
falseand is intended only as a temporary compatibility escape hatch. Only applications already settingMaxAgeare affected. The behavioral compatibility announcement is aspnet/Announcements#537.Outcome
origin/main: missingauth_timewithmax_age=300completed authentication (302 Found) instead of the expected failure (400 Bad Request).OnTokenValidatedwith one missingauth_timesucceeded (302 Found) for both token-validator paths; after validating the replacement token, both rows fail authentication as expected (400 Bad Request).Microsoft.AspNetCore.Authentication.Testpassed 870, failed 0, skipped 3 (pre-existing skips), total 873.dotnet formatcompleted andgit diff --checkpassed.OnTokenValidated. Both fixes have regression coverage; no unresolved findings remain.PublicAPI.Unshipped.txtupdate.The guarantee is deliberately narrow: ASP.NET Core validates the trusted identity provider's signed
auth_timeassertion against the exactmax_ageit sent. It cannot prove the physical authentication ceremony or protect against a compromised identity provider that signs false data. UserInfo, refresh-token redemption, local cookie lifetime, and physical user interaction are not revalidated.