Skip to content

[Authentication] Validate OIDC auth_time against max_age - #69321

Open
javiercn wants to merge 3 commits into
mainfrom
javiercn/oidc-max-age-validation
Open

javiercn wants to merge 3 commits into
mainfrom
javiercn/oidc-max-age-validation

Conversation

@javiercn

@javiercn javiercn commented Sep 15, 2026

Copy link
Copy Markdown
Member

Fixes #67462

Overview

OpenIdConnectHandler already sent an effective max_age from OpenIdConnectChallengeProperties.MaxAge or OpenIdConnectOptions.MaxAge, but accepted an ID token without checking its signed auth_time. A faithful pre-fix end-to-end reproduction sent max_age=300 with no auth_time; authentication still completed with 302 Found instead of failing.

This change automatically validates auth_time whenever an effective MaxAge is sent. It adds no public API. A process-wide AppContext compatibility switch, Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation, is off by default; explicitly setting it to true restores the legacy request-only behavior for temporarily incompatible providers.

Design

The exact final ordinary authorization-request value is captured after OnRedirectToIdentityProvider and stored in protected AuthenticationProperties state. This preserves per-challenge-over-options precedence while preventing concurrent challenges from sharing mutable configuration:

// OpenIdConnectHandler.cs:481-485
message = redirectContext.ProtocolMessage;

if (!string.IsNullOrEmpty(message.MaxAge))
{
    properties.Items[MaxAgeProperty] = message.MaxAge;
}

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 after ValidateTokenResponse, so hybrid flows validate both tokens. The code-flow check uses the current jwt after OnTokenValidated, matching the token used by protocol validation when an application replaces TokenValidatedContext.SecurityToken:

// OpenIdConnectHandler.cs:828-831, 927-931
if (!string.IsNullOrEmpty(authorizationResponse.IdToken))
{
    ValidateAuthTime(jwt, maxAge);
}

// ... code redemption, OnTokenValidated, and token-response validation ...
ValidateAuthTime(jwt, maxAge);

auth_time must be an integral, nonnegative OIDC NumericDate representable by DateTimeOffset. Using Options.TimeProvider.GetUtcNow() and TokenValidationParameters.ClockSkew, validation rejects:

  • auth_time > now + skew
  • now - auth_time > max_age + skew

Exact 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_age in OnPushAuthorization is 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 in OnRedirectToIdentityProvider.

Implementation

  • Preserves existing OpenIdConnectChallengeProperties.MaxAge over OpenIdConnectOptions.MaxAge precedence and outgoing serialization.
  • Correlates the exact final ordinary redirect max_age through protected per-transaction state.
  • Supports the default TokenHandler/JsonWebToken path and legacy SecurityTokenValidator/JwtSecurityToken path.
  • Covers authorization code, implicit (id_token, id_token token), and hybrid (code token, code id_token, code id_token token) paths.
  • Validates both front- and back-channel ID tokens relied on by hybrid flows.
  • Leaves behavior unchanged when MaxAge is unset.
  • Documents the changed MaxAge contract, clock-skew behavior, PAR constraint, compatibility switch, and trust boundary in XML docs and PACKAGE.md.

The compatibility switch can be enabled in runtime configuration:

{
  "runtimeOptions": {
    "configProperties": {
      "Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation": true
    }
  }
}

or before authentication starts:

AppContext.SetSwitch(
    "Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation",
    true);

The switch defaults to false and is intended only as a temporary compatibility escape hatch. Only applications already setting MaxAge are affected. The behavioral compatibility announcement is aspnet/Announcements#537.

Outcome

  • Red reproduction on origin/main: missing auth_time with max_age=300 completed authentication (302 Found) instead of the expected failure (400 Bad Request).
  • Review regression red → green: before the follow-up fix, replacing the code-flow security token in OnTokenValidated with one missing auth_time succeeded (302 Found) for both token-validator paths; after validating the replacement token, both rows fail authentication as expected (400 Bad Request).
  • Focused green coverage: 43/43 cases passed, including options/per-challenge precedence, protected correlation and concurrent isolation, exact/stale/future/skew boundaries, invalid NumericDates, every supported flow shape, both token handlers, token replacement, event ordering, downstream termination, PAR behavior, and switch unset/false/true behavior.
  • Full affected project: Microsoft.AspNetCore.Authentication.Test passed 870, failed 0, skipped 3 (pre-existing skips), total 873.
  • Formatting/diff: focused dotnet format completed and git diff --check passed.
  • Independent ASP.NET Core review: found and resolved two high-confidence issues: the PAR mutation guard originally ran when validation was disabled or push was skipped, and code-only flow originally validated the token-endpoint JWT rather than a security token replacement accepted by OnTokenValidated. Both fixes have regression coverage; no unresolved findings remain.
  • Compatibility: [Breaking change]: OpenID Connect max_age validates auth_time aspnet/Announcements#537 documents the behavioral change, affected applications, exact failure conditions, trust boundary, and temporary switch.
  • Public API: none added or changed; no PublicAPI.Unshipped.txt update.

The guarantee is deliberately narrow: ASP.NET Core validates the trusted identity provider's signed auth_time assertion against the exact max_age it 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.

@github-actions github-actions Bot added the area-auth Includes: authentication, authorization, OAuth, OIDC, and access token validation label Sep 15, 2026
@javiercn
javiercn marked this pull request as ready for review September 16, 2026 10:45
Copilot AI lite review requested due to automatic review settings September 16, 2026 10:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.TimeProvider is nullable and is normally left unset; AuthenticationHandler applies the TimeProvider.System fallback only to its inherited TimeProvider property. Therefore a normal application that enables MaxAge reaches this line with Options.TimeProvider == null and throws NullReferenceException instead of validating auth_time (the new tests hide this by always assigning the option). Use the handler's normalized TimeProvider property.
        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.

Comment on lines +828 to +831
if (!string.IsNullOrEmpty(authorizationResponse.IdToken))
{
ValidateAuthTime(jwt, maxAge);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 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.

Comment on lines +483 to +486
if (!string.IsNullOrEmpty(message.MaxAge))
{
properties.Items[MaxAgeProperty] = message.MaxAge;
}
Comment on lines +988 to +994
if (!long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var maxAge))
{
throw new SecurityTokenValidationException("The correlated max_age value is invalid.");
}

return maxAge;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NumberStyles.None already rejects the minus sign, so this parse doesn’t accept negative values.

Comment on lines +828 to +831
if (!string.IsNullOrEmpty(authorizationResponse.IdToken))
{
ValidateAuthTime(jwt, maxAge);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
var now = Options.TimeProvider!.GetUtcNow();
var now = TimeProvider.GetUtcNow();

Comment on lines +1028 to +1086
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;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment on lines +988 to +994
if (!long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var maxAge))
{
throw new SecurityTokenValidationException("The correlated max_age value is invalid.");
}

return maxAge;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applications that do not set MaxAge are unaffected.

This is mostly true, but there are some other ways you might be impacted (e.g. max_age through AdditionalAuthorizationParameters or OnRedirectToIdentityProvider).

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-auth Includes: authentication, authorization, OAuth, OIDC, and access token validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenIdConnectHandler does not validate auth_time against configured MaxAge on the callback

3 participants