Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ public OpenIdConnectChallengeProperties(IDictionary<string, string?> items, IDic
{ }

/// <summary>
/// The "max_age" parameter value being used for a challenge request.
/// The "max_age" parameter value being used for a challenge request. This overrides <see cref="OpenIdConnectOptions.MaxAge"/>.
/// The handler validates the identity provider's signed "auth_time" claim against the value sent for this challenge.
/// </summary>
public TimeSpan? MaxAge
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public class OpenIdConnectHandler : RemoteAuthenticationHandler<OpenIdConnectOpt
{
private const string NonceProperty = "N";
private const string HeaderValueEpocDate = "Thu, 01 Jan 1970 00:00:00 GMT";
private const string MaxAgeProperty = ".OpenIdConnect.MaxAge";
private const string DisableMaxAgeValidationSwitch = "Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation";

private OpenIdConnectConfiguration? _configuration;

Expand Down Expand Up @@ -478,6 +480,15 @@ private async Task HandleChallengeAsyncInternal(AuthenticationProperties propert

message = redirectContext.ProtocolMessage;

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

if (!string.IsNullOrEmpty(message.State))
{
properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State;
Expand Down Expand Up @@ -590,6 +601,13 @@ private async Task PushAuthorizationRequest(OpenIdConnectMessage authorizeReques
return;
}

if (!IsMaxAgeValidationDisabled() &&
!string.Equals(parRequest.MaxAge, authorizeRequest.MaxAge, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"The max_age parameter cannot be changed in OnPushAuthorization. Change it in OnRedirectToIdentityProvider so that the value can be correlated with the authorization response.");
}

// ... or handle pushing to the par endpoint itself, in which case it will supply the request uri
if (context.HandledPush)
{
Expand Down Expand Up @@ -734,6 +752,8 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
return HandleRequestResult.Fail("Correlation failed.", properties);
}

var maxAge = ReadMaxAge(properties);

// if any of the error fields are set, throw error null
if (!string.IsNullOrEmpty(authorizationResponse.Error))
{
Expand Down Expand Up @@ -809,6 +829,11 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
Nonce = nonce
});

if (jwt is not null)
{
ValidateAuthTime(jwt, maxAge);
}

OpenIdConnectMessage? tokenEndpointResponse = null;

// Authorization Code or Hybrid flow
Expand Down Expand Up @@ -906,6 +931,8 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
Nonce = nonce
});
}

ValidateAuthTime(jwt, maxAge);
}

if (Options.SaveTokens)
Expand Down Expand Up @@ -955,6 +982,66 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
}
}

private static long? ReadMaxAge(AuthenticationProperties properties)
{
if (!properties.Items.Remove(MaxAgeProperty, out var value) || IsMaxAgeValidationDisabled())
{
return null;
}

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

return maxAge;
}
Comment thread
javiercn marked this conversation as resolved.

private static bool IsMaxAgeValidationDisabled() =>
AppContext.TryGetSwitch(DisableMaxAgeValidationSwitch, out var disabled) && disabled;

private void ValidateAuthTime(JwtSecurityToken? token, long? maxAge)
{
if (!maxAge.HasValue)
{
return;
}

if (token is null ||
!token.Payload.TryGetValue(JwtRegisteredClaimNames.AuthTime, out var value) ||
!TryReadNumericDate(value, out var authTime))
{
throw new SecurityTokenValidationException("The auth_time claim must be a valid integral NumericDate when max_age is requested.");
}

var now = TimeProvider.GetUtcNow();
var nowSeconds = (decimal)(now - DateTimeOffset.UnixEpoch).Ticks / TimeSpan.TicksPerSecond;
var skewSeconds = (decimal)Options.TokenValidationParameters.ClockSkew.Ticks / TimeSpan.TicksPerSecond;

if (authTime > nowSeconds + skewSeconds)
{
throw new SecurityTokenValidationException("The auth_time claim is later than the current time plus the allowed clock skew.");
}

if (nowSeconds - authTime > maxAge.Value + skewSeconds)
{
throw new SecurityTokenValidationException("The auth_time claim exceeds the requested max_age plus the allowed clock skew.");
}
}

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();
}
Comment thread
javiercn marked this conversation as resolved.

private AuthenticationProperties? ReadPropertiesAndClearState(OpenIdConnectMessage message)
{
AuthenticationProperties? properties = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,11 @@ public override void Validate()
}

/// <summary>
/// Gets or sets the 'max_age'. If set the 'max_age' parameter will be sent with the authentication request. If the identity
/// Gets or sets the 'max_age'. If set, the 'max_age' parameter will be sent with the authentication request. If the identity
/// provider has not actively authenticated the user within the length of time specified, the user will be prompted to
/// re-authenticate. By default no max_age is specified.
/// re-authenticate. The handler validates the identity provider's signed 'auth_time' claim against the sent value, allowing
/// the clock skew configured by <see cref="TokenValidationParameters.ClockSkew"/>. This validation trusts the identity provider's
/// assertion and cannot prove that a particular authentication ceremony occurred. By default no max_age is specified.
/// </summary>
public TimeSpan? MaxAge { get; set; }

Expand Down
30 changes: 30 additions & 0 deletions src/Security/Authentication/OpenIdConnect/src/PACKAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ The main types provided by `Microsoft.AspNetCore.Authentication.OpenIdConnect` a

For more information on these types and their usage, refer to the [official documentation](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.authentication.openidconnect).

## `max_age` validation

When `OpenIdConnectOptions.MaxAge` or `OpenIdConnectChallengeProperties.MaxAge` is set, the handler sends `max_age` and validates the signed ID token's `auth_time` claim against the exact value sent for that authorization transaction. The claim must be an integral, nonnegative OpenID Connect NumericDate. Missing, malformed, future, or stale values fail authentication after normal token and protocol validation. `OpenIdConnectOptions.TokenValidationParameters.ClockSkew` applies to both future and stale comparisons.

This validation checks the trusted identity provider's signed assertion. It does not prove that a particular authentication ceremony occurred and cannot protect an application from an identity provider that signs false data. It does not validate UserInfo or refresh-token responses and does not change the local authentication cookie lifetime.

Applications using a temporarily incompatible identity provider can restore the earlier request-only behavior with the `Microsoft.AspNetCore.Authentication.OpenIdConnect.DisableMaxAgeValidation` compatibility switch:

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

The equivalent process-wide code configuration is:

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

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.

## Additional Documentation

For additional documentation on using OpenID Connect authentication in ASP.NET Core, you can refer to the following resources:
Expand Down
Loading
Loading