diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 5321d385e2f9..2691385519be 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -185,9 +185,9 @@ https://github.com/dotnet/runtime 40480e8e82b734a54c210f656361ff073353ffbe - + https://github.com/dotnet/source-build-externals - 76026f9224bd83ede7b2f494912694a30169c233 + 844e2cd86e7525d7eb32358e63a0c554187eb26b diff --git a/eng/Versions.props b/eng/Versions.props index 17702f1b2145..20e7d209e43a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -11,6 +11,7 @@ 0 7 true + 7.0.0-preview @@ -162,7 +163,7 @@ 8.0.0-beta.23361.2 - 8.0.0-alpha.1.23362.1 + 8.0.0-alpha.1.23368.1 8.0.0-alpha.1.23362.3 @@ -254,15 +255,15 @@ 1.1.2-beta1.22531.1 1.1.2-beta1.22531.1 1.0.0-20230414.1 - 6.15.1 - 6.15.1 - 6.15.1 + $(IdentityModelVersion) + $(IdentityModelVersion) + $(IdentityModelVersion) 2.2.1 1.0.1 3.0.1 3.0.1 11.1.0 - 6.21.0 + $(IdentityModelVersion) 5.0.0 5.0.0-alpha.20560.6 5.0.0 diff --git a/src/Security/Authentication/JwtBearer/src/AuthenticateResults.cs b/src/Security/Authentication/JwtBearer/src/AuthenticateResults.cs index 5f0e8bb6b914..c66ea3304f1d 100644 --- a/src/Security/Authentication/JwtBearer/src/AuthenticateResults.cs +++ b/src/Security/Authentication/JwtBearer/src/AuthenticateResults.cs @@ -6,4 +6,5 @@ namespace Microsoft.AspNetCore.Authentication.JwtBearer; internal static class AuthenticateResults { internal static AuthenticateResult ValidatorNotFound = AuthenticateResult.Fail("No SecurityTokenValidator available for token."); + internal static AuthenticateResult TokenHandlerUnableToValidate = AuthenticateResult.Fail("No TokenHandler was able to validate the token."); } diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs index ff4767430d57..c880cb555380 100644 --- a/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs +++ b/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; @@ -20,8 +19,6 @@ namespace Microsoft.AspNetCore.Authentication.JwtBearer; /// public class JwtBearerHandler : AuthenticationHandler { - private OpenIdConnectConfiguration? _configuration; - /// /// Initializes a new instance of . /// @@ -96,79 +93,88 @@ protected override async Task HandleAuthenticateAsync() } } - if (_configuration == null && Options.ConfigurationManager != null) - { - _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); - } - - var validationParameters = Options.TokenValidationParameters.Clone(); - if (_configuration != null) - { - var issuers = new[] { _configuration.Issuer }; - validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers; - - validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys) - ?? _configuration.SigningKeys; - } - + var tvp = await SetupTokenValidationParametersAsync(); List? validationFailures = null; SecurityToken? validatedToken = null; - foreach (var validator in Options.SecurityTokenValidators) + ClaimsPrincipal? principal = null; + + if (!Options.UseSecurityTokenValidators) { - if (validator.CanReadToken(token)) + foreach (var tokenHandler in Options.TokenHandlers) { - ClaimsPrincipal principal; try { - principal = validator.ValidateToken(token, validationParameters, out validatedToken); + var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tvp); + if (tokenValidationResult.IsValid) + { + principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity); + validatedToken = tokenValidationResult.SecurityToken; + break; + } + else + { + validationFailures ??= new List(1); + RecordTokenValidationError(tokenValidationResult.Exception ?? new SecurityTokenValidationException($"The TokenHandler: '{tokenHandler}', was unable to validate the Token."), validationFailures); + } } catch (Exception ex) { - Logger.TokenValidationFailed(ex); - - // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event. - if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null - && ex is SecurityTokenSignatureKeyNotFoundException) + validationFailures ??= new List(1); + RecordTokenValidationError(ex, validationFailures); + } + } + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete + foreach (var validator in Options.SecurityTokenValidators) + { + if (validator.CanReadToken(token)) + { + try { - Options.ConfigurationManager.RequestRefresh(); + principal = validator.ValidateToken(token, tvp, out validatedToken); } - - if (validationFailures == null) + catch (Exception ex) { - validationFailures = new List(1); + validationFailures ??= new List(1); + RecordTokenValidationError(ex, validationFailures); + continue; } - validationFailures.Add(ex); - continue; } + } +#pragma warning restore CS0618 // Type or member is obsolete + } - Logger.TokenValidationSucceeded(); + if (principal != null && validatedToken != null) + { + Logger.TokenValidationSucceeded(); - var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options) - { - Principal = principal, - SecurityToken = validatedToken - }; + var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options) + { + Principal = principal + }; - tokenValidatedContext.Properties.ExpiresUtc = GetSafeDateTime(validatedToken.ValidTo); - tokenValidatedContext.Properties.IssuedUtc = GetSafeDateTime(validatedToken.ValidFrom); + tokenValidatedContext.SecurityToken = validatedToken; + tokenValidatedContext.Properties.ExpiresUtc = GetSafeDateTime(validatedToken.ValidTo); + tokenValidatedContext.Properties.IssuedUtc = GetSafeDateTime(validatedToken.ValidFrom); - await Events.TokenValidated(tokenValidatedContext); - if (tokenValidatedContext.Result != null) - { - return tokenValidatedContext.Result; - } + await Events.TokenValidated(tokenValidatedContext); + if (tokenValidatedContext.Result != null) + { + return tokenValidatedContext.Result; + } - if (Options.SaveToken) + if (Options.SaveToken) + { + tokenValidatedContext.Properties.StoreTokens(new[] { - tokenValidatedContext.Properties.StoreTokens(new[] - { - new AuthenticationToken { Name = "access_token", Value = token } - }); - } - - tokenValidatedContext.Success(); - return tokenValidatedContext.Result!; + new AuthenticationToken { Name = "access_token", Value = token } + }); } + + tokenValidatedContext.Success(); + return tokenValidatedContext.Result!; } if (validationFailures != null) @@ -187,6 +193,11 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Fail(authenticationFailedContext.Exception); } + if (!Options.UseSecurityTokenValidators) + { + return AuthenticateResults.TokenHandlerUnableToValidate; + } + return AuthenticateResults.ValidatorNotFound; } catch (Exception ex) @@ -208,6 +219,47 @@ protected override async Task HandleAuthenticateAsync() } } + private void RecordTokenValidationError(Exception exception, List exceptions) + { + if (exception != null) + { + Logger.TokenValidationFailed(exception); + exceptions.Add(exception); + } + + // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the event. + // Refreshing on SecurityTokenSignatureKeyNotFound may be redundant if Last-Known-Good is enabled, it won't do much harm, most likely will be a nop. + if (Options.RefreshOnIssuerKeyNotFound && Options.ConfigurationManager != null + && exception is SecurityTokenSignatureKeyNotFoundException) + { + Options.ConfigurationManager.RequestRefresh(); + } + } + + private async Task SetupTokenValidationParametersAsync() + { + // Clone to avoid cross request race conditions for updated configurations. + var tokenValidationParameters = Options.TokenValidationParameters.Clone(); + + if (Options.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) + { + tokenValidationParameters.ConfigurationManager = baseConfigurationManager; + } + else + { + if (Options.ConfigurationManager != null) + { + // GetConfigurationAsync has a time interval that must pass before new http request will be issued. + var configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + var issuers = new[] { configuration.Issuer }; + tokenValidationParameters.ValidIssuers = (tokenValidationParameters.ValidIssuers == null ? issuers : tokenValidationParameters.ValidIssuers.Concat(issuers)); + tokenValidationParameters.IssuerSigningKeys = (tokenValidationParameters.IssuerSigningKeys == null ? configuration.SigningKeys : tokenValidationParameters.IssuerSigningKeys.Concat(configuration.SigningKeys)); + } + } + + return tokenValidationParameters; + } + private static DateTime? GetSafeDateTime(DateTime dateTime) { // Assigning DateTime.MinValue or default(DateTime) to a DateTimeOffset when in a UTC+X timezone will throw diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerOptions.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerOptions.cs index db007691f3b9..f5b56ed32531 100644 --- a/src/Security/Authentication/JwtBearer/src/JwtBearerOptions.cs +++ b/src/Security/Authentication/JwtBearer/src/JwtBearerOptions.cs @@ -5,6 +5,7 @@ using System.Net.Http; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Authentication.JwtBearer; @@ -15,13 +16,22 @@ namespace Microsoft.AspNetCore.Authentication.JwtBearer; public class JwtBearerOptions : AuthenticationSchemeOptions { private readonly JwtSecurityTokenHandler _defaultHandler = new JwtSecurityTokenHandler(); + private readonly JsonWebTokenHandler _defaultTokenHandler = new JsonWebTokenHandler + { + MapInboundClaims = JwtSecurityTokenHandler.DefaultMapInboundClaims + }; + + private bool _mapInboundClaims = JwtSecurityTokenHandler.DefaultMapInboundClaims; /// /// Initializes a new instance of . /// public JwtBearerOptions() { +#pragma warning disable CS0618 // Type or member is obsolete SecurityTokenValidators = new List { _defaultHandler }; +#pragma warning restore CS0618 // Type or member is obsolete + TokenHandlers = new List { _defaultTokenHandler }; } /// @@ -103,8 +113,14 @@ public JwtBearerOptions() /// /// Gets the ordered list of used to validate access tokens. /// + [Obsolete("SecurityTokenValidators is no longer used by default. Use TokenHandlers instead. To continue using SecurityTokenValidators, set UseSecurityTokenValidators to true. See https://aka.ms/aspnetcore8/security-token-changes")] public IList SecurityTokenValidators { get; private set; } + /// + /// Gets the ordered list of used to validate access tokens. + /// + public IList TokenHandlers { get; private set; } + /// /// Gets or sets the parameters used to validate identity tokens. /// @@ -126,15 +142,20 @@ public JwtBearerOptions() public bool IncludeErrorDetails { get; set; } = true; /// - /// Gets or sets the property on the default instance of in SecurityTokenValidators, which is used when determining - /// whether or not to map claim types that are extracted when validating a . + /// Gets or sets the property on the default instance of in SecurityTokenValidators, or in TokenHandlers, which is used when determining + /// whether or not to map claim types that are extracted when validating a or a . /// If this is set to true, the Claim Type is set to the JSON claim 'name' after translating using this mapping. Otherwise, no mapping occurs. /// The default value is true. /// public bool MapInboundClaims { - get => _defaultHandler.MapInboundClaims; - set => _defaultHandler.MapInboundClaims = value; + get => _mapInboundClaims; + set + { + _mapInboundClaims = value; + _defaultHandler.MapInboundClaims = value; + _defaultTokenHandler.MapInboundClaims = value; + } } /// @@ -152,4 +173,17 @@ public bool MapInboundClaims /// Defaults to . /// public TimeSpan RefreshInterval { get; set; } = ConfigurationManager.DefaultRefreshInterval; + + /// + /// Gets or sets whether or will be used to validate the inbound token. + /// + /// + /// The advantages of using TokenHandlers are: + /// There is an Async model. + /// The default token handler is a which is faster than a . + /// There is an ability to make use of a Last-Known-Good model for metadata that protects applications when metadata is published with errors. + /// SecurityTokenValidators can be used when needs a . + /// When using TokenHandlers, will be a . + /// + public bool UseSecurityTokenValidators { get; set; } } diff --git a/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt index d9d3a043b473..4626d5e95af8 100644 --- a/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt +++ b/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt @@ -1,2 +1,5 @@ #nullable enable Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler.JwtBearerHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder) -> void +Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions.TokenHandlers.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions.UseSecurityTokenValidators.get -> bool +Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions.UseSecurityTokenValidators.set -> void diff --git a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs index 3c063b244100..7c53f799c443 100644 --- a/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs +++ b/src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs @@ -266,7 +266,7 @@ await WriteHtmlAsync(response, async res => // Persist the new acess token props.UpdateTokenValue("access_token", payload.RootElement.GetString("access_token")); props.UpdateTokenValue("refresh_token", payload.RootElement.GetString("refresh_token")); - if (payload.RootElement.TryGetProperty("expires_in", out var property) && property.TryGetInt32(out var seconds)) + if (payload.RootElement.TryGetProperty("expires_in", out var property) && int.TryParse(property.GetString(), out var seconds)) { var expiresAt = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(seconds); props.UpdateTokenValue("expires_at", expiresAt.ToString("o", CultureInfo.InvariantCulture)); @@ -283,7 +283,7 @@ await WriteHtmlAsync(response, async res => await WriteTableHeader(res, new string[] { "Token Type", "Value" }, props.GetTokens().Select(token => new string[] { token.Name, token.Value })); await res.WriteAsync("

Payload:

"); - await res.WriteAsync(HtmlEncoder.Default.Encode(payload.ToString()).Replace(",", ",
") + "
"); + await res.WriteAsync(HtmlEncoder.Default.Encode(payload.RootElement.ToString()).Replace(",", ",
") + "
"); }); } diff --git a/src/Security/Authentication/OpenIdConnect/src/LoggingExtensions.cs b/src/Security/Authentication/OpenIdConnect/src/LoggingExtensions.cs index c0cc19bbf12a..3be7240ab4a7 100644 --- a/src/Security/Authentication/OpenIdConnect/src/LoggingExtensions.cs +++ b/src/Security/Authentication/OpenIdConnect/src/LoggingExtensions.cs @@ -164,4 +164,10 @@ internal static partial class LoggingExtensions [LoggerMessage(55, LogLevel.Error, "The remote signout request was ignored because the 'iss' parameter didn't match " + "the expected value, which may indicate an unsolicited logout.", EventName = "RemoteSignOutIssuerInvalid")] public static partial void RemoteSignOutIssuerInvalid(this ILogger logger); + + [LoggerMessage(56, LogLevel.Error, "Unable to validate the 'id_token', no suitable TokenHandler was found for: '{IdToken}'.", EventName = "UnableToValidateIdTokenFromHandler")] + public static partial void UnableToValidateIdTokenFromHandler(this ILogger logger, string idToken); + + [LoggerMessage(57, LogLevel.Error, "The Validated Security Token must be of type JsonWebToken, but instead its type is: '{SecurityTokenType}.'", EventName = "InvalidSecurityTokenTypeFromHandler")] + public static partial void InvalidSecurityTokenTypeFromHandler(this ILogger logger, Type? securityTokenType); } diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs index 1f2c620def33..4b074f5621e3 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs @@ -17,8 +17,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; namespace Microsoft.AspNetCore.Authentication.OpenIdConnect; @@ -649,7 +651,17 @@ protected override async Task HandleRemoteAuthenticateAsync if (!string.IsNullOrEmpty(authorizationResponse.IdToken)) { Logger.ReceivedIdToken(); - user = ValidateToken(authorizationResponse.IdToken, properties, validationParameters, out jwt); + + if (!Options.UseSecurityTokenValidator) + { + var tokenValidationResult = await ValidateTokenUsingHandlerAsync(authorizationResponse.IdToken, properties, validationParameters); + user = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity); + jwt = JwtSecurityTokenConverter.Convert(tokenValidationResult.SecurityToken as JsonWebToken); + } + else + { + user = ValidateToken(authorizationResponse.IdToken, properties, validationParameters, out jwt); + } nonce = jwt.Payload.Nonce; if (!string.IsNullOrEmpty(nonce)) @@ -717,7 +729,19 @@ protected override async Task HandleRemoteAuthenticateAsync // At least a cursory validation is required on the new IdToken, even if we've already validated the one from the authorization response. // And we'll want to validate the new JWT in ValidateTokenResponse. - var tokenEndpointUser = ValidateToken(tokenEndpointResponse.IdToken, properties, validationParameters, out var tokenEndpointJwt); + ClaimsPrincipal tokenEndpointUser; + JwtSecurityToken tokenEndpointJwt; + + if (!Options.UseSecurityTokenValidator) + { + var tokenValidationResult = await ValidateTokenUsingHandlerAsync(tokenEndpointResponse.IdToken, properties, validationParameters); + tokenEndpointUser = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity); + tokenEndpointJwt = JwtSecurityTokenConverter.Convert(tokenValidationResult.SecurityToken as JsonWebToken); + } + else + { + tokenEndpointUser = ValidateToken(tokenEndpointResponse.IdToken, properties, validationParameters, out tokenEndpointJwt); + } // Avoid reading & deleting the nonce cookie, running the event, etc, if it was already done as part of the authorization response validation. if (user == null) @@ -1244,11 +1268,13 @@ private async Task RunAuthenticationFailedEventAsyn // Note this modifies properties if Options.UseTokenLifetime private ClaimsPrincipal ValidateToken(string idToken, AuthenticationProperties properties, TokenValidationParameters validationParameters, out JwtSecurityToken jwt) { +#pragma warning disable CS0618 // Type or member is obsolete if (!Options.SecurityTokenValidator.CanReadToken(idToken)) { Logger.UnableToReadIdToken(idToken); throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateToken, idToken)); } +#pragma warning restore CS0618 // Type or member is obsolete if (_configuration != null) { @@ -1259,7 +1285,9 @@ private ClaimsPrincipal ValidateToken(string idToken, AuthenticationProperties p ?? _configuration.SigningKeys; } +#pragma warning disable CS0618 // Type or member is obsolete var principal = Options.SecurityTokenValidator.ValidateToken(idToken, validationParameters, out SecurityToken validatedToken); +#pragma warning restore CS0618 // Type or member is obsolete if (validatedToken is JwtSecurityToken validatedJwt) { jwt = validatedJwt; @@ -1294,6 +1322,61 @@ private ClaimsPrincipal ValidateToken(string idToken, AuthenticationProperties p return principal; } + // Note this modifies properties if Options.UseTokenLifetime + private async Task ValidateTokenUsingHandlerAsync(string idToken, AuthenticationProperties properties, TokenValidationParameters validationParameters) + { + if (Options.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) + { + validationParameters.ConfigurationManager = baseConfigurationManager; + } + else if (_configuration != null) + { + var issuer = new[] { _configuration.Issuer }; + validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuer) ?? issuer; + + validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys) + ?? _configuration.SigningKeys; + } + + var validationResult = await Options.TokenHandler.ValidateTokenAsync(idToken, validationParameters); + + if (validationResult.Exception != null) + { + throw validationResult.Exception; + } + + var validatedToken = validationResult.SecurityToken; + + if (!validationResult.IsValid || validatedToken == null) + { + Logger.UnableToValidateIdTokenFromHandler(idToken); + throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateTokenFromHandler, idToken)); + } + + if (validatedToken is not JsonWebToken) + { + Logger.InvalidSecurityTokenTypeFromHandler(validatedToken?.GetType()); + throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.ValidatedSecurityTokenNotJsonWebToken, validatedToken?.GetType())); + } + + if (Options.UseTokenLifetime) + { + var issued = validatedToken.ValidFrom; + if (issued != DateTime.MinValue) + { + properties.IssuedUtc = issued; + } + + var expires = validatedToken.ValidTo; + if (expires != DateTime.MinValue) + { + properties.ExpiresUtc = expires; + } + } + + return validationResult; + } + /// /// Build a redirect path if the given path is a relative path. /// diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs index 7fada340c927..0047d67ea828 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs @@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication.OAuth.Claims; using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -17,6 +18,12 @@ public class OpenIdConnectOptions : RemoteAuthenticationOptions { private CookieBuilder _nonceCookieBuilder; private readonly JwtSecurityTokenHandler _defaultHandler = new JwtSecurityTokenHandler(); + private readonly JsonWebTokenHandler _defaultTokenHandler = new JsonWebTokenHandler + { + MapInboundClaims = JwtSecurityTokenHandler.DefaultMapInboundClaims + }; + + private bool _mapInboundClaims = JwtSecurityTokenHandler.DefaultMapInboundClaims; /// /// Initializes a new @@ -37,7 +44,10 @@ public OpenIdConnectOptions() CallbackPath = new PathString("/signin-oidc"); SignedOutCallbackPath = new PathString("/signout-callback-oidc"); RemoteSignOutPath = new PathString("/signout-oidc"); +#pragma warning disable CS0618 // Type or member is obsolete SecurityTokenValidator = _defaultHandler; +#pragma warning restore CS0618 // Type or member is obsolete + TokenHandler = _defaultTokenHandler; Events = new OpenIdConnectEvents(); Scope.Add("openid"); @@ -253,8 +263,17 @@ public override void Validate() /// /// Gets or sets the used to validate identity tokens. /// + [Obsolete("SecurityTokenValidator is no longer used by default. Use TokenHandler instead. To continue using SecurityTokenValidator, set UseSecurityTokenValidator to true. See https://aka.ms/aspnetcore8/security-token-changes")] public ISecurityTokenValidator SecurityTokenValidator { get; set; } + /// + /// Gets or sets the used to validate identity tokens. + /// + /// This will be used instead of if is . + /// + /// + public TokenHandler TokenHandler { get; set; } + /// /// Gets or sets the parameters used to validate identity tokens. /// @@ -353,14 +372,33 @@ public override CookieOptions Build(HttpContext context, DateTimeOffset expiresF public TimeSpan RefreshInterval { get; set; } = ConfigurationManager.DefaultRefreshInterval; /// - /// Gets or sets the property on the default instance of in SecurityTokenValidator, which is used when determining + /// Gets or sets the property on the default instance of in SecurityTokenValidator + /// and default instance of in TokenHandler, which is used when determining /// whether or not to map claim types that are extracted when validating a . /// If this is set to true, the Claim Type is set to the JSON claim 'name' after translating using this mapping. Otherwise, no mapping occurs. /// The default value is true. /// public bool MapInboundClaims { - get => _defaultHandler.MapInboundClaims; - set => _defaultHandler.MapInboundClaims = value; + get => _mapInboundClaims; + set + { + _mapInboundClaims = value; + _defaultHandler.MapInboundClaims = value; + _defaultTokenHandler.MapInboundClaims = value; + } } + + /// + /// Gets or sets whether to use the or the for validating identity tokens. + /// + /// + /// The advantages of using TokenHandler are: + /// There is an Async model. + /// The default token handler is a which is faster than a . + /// There is an ability to make use of a Last-Known-Good model for metadata that protects applications when metadata is published with errors. + /// SecurityTokenValidator can be used when needs a . + /// When using TokenHandler, will be a . + /// + public bool UseSecurityTokenValidator { get; set; } } diff --git a/src/Security/Authentication/OpenIdConnect/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/OpenIdConnect/src/PublicAPI.Unshipped.txt index 8ff5e3305e99..d6dbc14fed1e 100644 --- a/src/Security/Authentication/OpenIdConnect/src/PublicAPI.Unshipped.txt +++ b/src/Security/Authentication/OpenIdConnect/src/PublicAPI.Unshipped.txt @@ -1,2 +1,6 @@ #nullable enable Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler.OpenIdConnectHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.HtmlEncoder! htmlEncoder, System.Text.Encodings.Web.UrlEncoder! encoder) -> void +Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.TokenHandler.get -> Microsoft.IdentityModel.Tokens.TokenHandler! +Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.TokenHandler.set -> void +Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.UseSecurityTokenValidator.get -> bool +Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.UseSecurityTokenValidator.set -> void diff --git a/src/Security/Authentication/OpenIdConnect/src/Resources.resx b/src/Security/Authentication/OpenIdConnect/src/Resources.resx index 7f790fef43f5..b2a996626273 100644 --- a/src/Security/Authentication/OpenIdConnect/src/Resources.resx +++ b/src/Security/Authentication/OpenIdConnect/src/Resources.resx @@ -135,4 +135,10 @@ Cannot process the message. Both id_token and code are missing. + + The Validated Security Token must be of type JsonWebToken, but instead its type is '{0}'. + + + Unable to validate the 'id_token', no suitable TokenHandler was found for: '{0}'." + \ No newline at end of file diff --git a/src/Security/Authentication/WsFederation/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/WsFederation/src/PublicAPI.Unshipped.txt index 6586d9439638..473f251c9c8a 100644 --- a/src/Security/Authentication/WsFederation/src/PublicAPI.Unshipped.txt +++ b/src/Security/Authentication/WsFederation/src/PublicAPI.Unshipped.txt @@ -1,2 +1,5 @@ #nullable enable Microsoft.AspNetCore.Authentication.WsFederation.WsFederationHandler.WsFederationHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder) -> void +Microsoft.AspNetCore.Authentication.WsFederation.WsFederationOptions.TokenHandlers.get -> System.Collections.Generic.ICollection! +Microsoft.AspNetCore.Authentication.WsFederation.WsFederationOptions.UseSecurityTokenHandlers.get -> bool +Microsoft.AspNetCore.Authentication.WsFederation.WsFederationOptions.UseSecurityTokenHandlers.set -> void diff --git a/src/Security/Authentication/WsFederation/src/Resources.resx b/src/Security/Authentication/WsFederation/src/Resources.resx index e2edafb671bd..d006fa4ea32e 100644 --- a/src/Security/Authentication/WsFederation/src/Resources.resx +++ b/src/Security/Authentication/WsFederation/src/Resources.resx @@ -121,7 +121,7 @@ The service descriptor is missing. - No token validator was found for the given token. + No token validator or token handler was found for the given token. The '{0}' option must be provided. diff --git a/src/Security/Authentication/WsFederation/src/WsFederationHandler.cs b/src/Security/Authentication/WsFederation/src/WsFederationHandler.cs index ca52664613ba..d00103ddb26a 100644 --- a/src/Security/Authentication/WsFederation/src/WsFederationHandler.cs +++ b/src/Security/Authentication/WsFederation/src/WsFederationHandler.cs @@ -176,6 +176,7 @@ protected override async Task HandleRemoteAuthenticateAsync return HandleRequestResults.NoMessage; } + List? validationFailures = null; try { // Retrieve our cached redirect uri @@ -241,42 +242,87 @@ protected override async Task HandleRemoteAuthenticateAsync wsFederationMessage = securityTokenReceivedContext.ProtocolMessage; properties = messageReceivedContext.Properties!; - if (_configuration == null) + var tvp = await SetupTokenValidationParametersAsync(); + ClaimsPrincipal? principal = null; + SecurityToken? validatedToken = null; + if (!Options.UseSecurityTokenHandlers) { - _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + foreach (var tokenHandler in Options.TokenHandlers) + { + try + { + var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tvp); + if (tokenValidationResult.IsValid) + { + principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity); + validatedToken = tokenValidationResult.SecurityToken; + break; + } + else + { + validationFailures ??= new List(1); + Exception exception = tokenValidationResult.Exception ?? new SecurityTokenValidationException($"The TokenHandler: '{tokenHandler}', was unable to validate the Token."); + validationFailures.Add(exception); + RequestRefresh(exception); + } + } + catch (Exception ex) + { + validationFailures ??= new List(1); + validationFailures.Add(new SecurityTokenValidationException($"TokenHandler: '{tokenHandler}', threw an exception (see inner exception).", ex)); + RequestRefresh(ex); + } + } } - - // Copy and augment to avoid cross request race conditions for updated configurations. - var tvp = Options.TokenValidationParameters.Clone(); - var issuers = new[] { _configuration.Issuer }; - tvp.ValidIssuers = (tvp.ValidIssuers == null ? issuers : tvp.ValidIssuers.Concat(issuers)); - tvp.IssuerSigningKeys = (tvp.IssuerSigningKeys == null ? _configuration.SigningKeys : tvp.IssuerSigningKeys.Concat(_configuration.SigningKeys)); - - ClaimsPrincipal? principal = null; - SecurityToken? parsedToken = null; - foreach (var validator in Options.SecurityTokenHandlers) + else { - if (validator.CanReadToken(token)) + +#pragma warning disable CS0618 // Type or member is obsolete + foreach (var validator in Options.SecurityTokenHandlers) { - principal = validator.ValidateToken(token, tvp, out parsedToken); - break; + if (validator.CanReadToken(token)) + { + try + { + principal = validator.ValidateToken(token, tvp, out validatedToken); + } + catch (Exception ex) + { + validationFailures ??= new List(1); + validationFailures.Add(ex); + continue; + } + break; + } } +#pragma warning restore CS0618 // Type or member is obsolete } if (principal == null) { - throw new SecurityTokenException(Resources.Exception_NoTokenValidatorFound); + if (validationFailures == null || validationFailures.Count == 0) + { + throw new SecurityTokenException(Resources.Exception_NoTokenValidatorFound); + } + else if (validationFailures.Count == 1) + { + throw new SecurityTokenException(Resources.Exception_NoTokenValidatorFound, validationFailures[0]); + } + else + { + throw new SecurityTokenException(Resources.Exception_NoTokenValidatorFound, new AggregateException(validationFailures)); + } } - if (Options.UseTokenLifetime && parsedToken != null) + if (Options.UseTokenLifetime && validatedToken != null) { // Override any session persistence to match the token lifetime. - var issued = parsedToken.ValidFrom; + var issued = validatedToken.ValidFrom; if (issued != DateTime.MinValue) { properties.IssuedUtc = issued.ToUniversalTime(); } - var expires = parsedToken.ValidTo; + var expires = validatedToken.ValidTo; if (expires != DateTime.MinValue) { properties.ExpiresUtc = expires.ToUniversalTime(); @@ -287,7 +333,7 @@ protected override async Task HandleRemoteAuthenticateAsync var securityTokenValidatedContext = new SecurityTokenValidatedContext(Context, Scheme, Options, principal, properties) { ProtocolMessage = wsFederationMessage, - SecurityToken = parsedToken, + SecurityToken = validatedToken, }; await Events.SecurityTokenValidated(securityTokenValidatedContext); @@ -306,17 +352,13 @@ protected override async Task HandleRemoteAuthenticateAsync { Logger.ExceptionProcessingMessage(exception); - // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification. - if (Options.RefreshOnIssuerKeyNotFound && exception is SecurityTokenSignatureKeyNotFoundException) - { - Options.ConfigurationManager.RequestRefresh(); - } - + RequestRefresh(exception); var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options) { ProtocolMessage = wsFederationMessage, Exception = exception }; + await Events.AuthenticationFailed(authenticationFailedContext); if (authenticationFailedContext.Result != null) { @@ -327,6 +369,41 @@ protected override async Task HandleRemoteAuthenticateAsync } } + private async Task SetupTokenValidationParametersAsync() + { + // Clone to avoid cross request race conditions for updated configurations. + var tokenValidationParameters = Options.TokenValidationParameters.Clone(); + + if (Options.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) + { + tokenValidationParameters.ConfigurationManager = baseConfigurationManager; + } + else + { + if (Options.ConfigurationManager != null) + { + // GetConfigurationAsync has a time interval that must pass before new http request will be issued. + _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + + var issuers = new[] { _configuration.Issuer }; + tokenValidationParameters.ValidIssuers = (tokenValidationParameters.ValidIssuers == null ? issuers : tokenValidationParameters.ValidIssuers.Concat(issuers)); + tokenValidationParameters.IssuerSigningKeys = (tokenValidationParameters.IssuerSigningKeys == null ? _configuration.SigningKeys : tokenValidationParameters.IssuerSigningKeys.Concat(_configuration.SigningKeys)); + } + } + + return tokenValidationParameters; + } + + private void RequestRefresh(Exception exception) + { + // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification. + // Refreshing on SecurityTokenSignatureKeyNotFound may be redundant if Last-Known-Good is enabled, it won't do much harm, most likely will be a nop. + if (Options.RefreshOnIssuerKeyNotFound && exception is SecurityTokenSignatureKeyNotFoundException) + { + Options.ConfigurationManager.RequestRefresh(); + } + } + /// /// Handles Signout /// diff --git a/src/Security/Authentication/WsFederation/src/WsFederationOptions.cs b/src/Security/Authentication/WsFederation/src/WsFederationOptions.cs index 81ec1f385bd5..ae043451d7bb 100644 --- a/src/Security/Authentication/WsFederation/src/WsFederationOptions.cs +++ b/src/Security/Authentication/WsFederation/src/WsFederationOptions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.WsFederation; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens.Saml; using Microsoft.IdentityModel.Tokens.Saml2; @@ -24,6 +25,7 @@ public class WsFederationOptions : RemoteAuthenticationOptions new SamlSecurityTokenHandler(), new JwtSecurityTokenHandler() }; + private TokenValidationParameters _tokenValidationParameters = new TokenValidationParameters(); /// @@ -37,6 +39,13 @@ public WsFederationOptions() // If you manage to get it configured, then you can set RemoteSignOutPath accordingly. RemoteSignOutPath = "/signin-wsfed"; Events = new WsFederationEvents(); + + TokenHandlers = new Collection() + { + new Saml2SecurityTokenHandler(), + new SamlSecurityTokenHandler(), + new JsonWebTokenHandler{ MapInboundClaims = JwtSecurityTokenHandler.DefaultMapInboundClaims } + }; } /// @@ -96,6 +105,7 @@ public override void Validate() /// /// Gets or sets the collection of used to read and validate the s. /// + [Obsolete("SecurityTokenHandlers is no longer used by default. Use TokenHandlers instead. To continue using SecurityTokenHandlers, set UseSecurityTokenHandlers to true. See https://aka.ms/aspnetcore8/security-token-changes")] public ICollection SecurityTokenHandlers { get @@ -108,6 +118,14 @@ public ICollection SecurityTokenHandlers } } + /// + /// Gets the collection of used to read and validate the s. + /// + public ICollection TokenHandlers + { + get; private set; + } + /// /// Gets or sets the type used to secure data handled by the middleware. /// @@ -181,4 +199,17 @@ public TokenValidationParameters TokenValidationParameters /// [EditorBrowsable(EditorBrowsableState.Never)] public new bool SaveTokens { get; set; } + + /// + /// Gets or sets whether or will be used to validate the inbound token. + /// + /// + /// The advantages of using the TokenHandlers are: + /// There is an Async model. + /// The default token handler for JsonWebTokens is a which is faster than a . + /// There is an ability to make use of a Last-Known-Good model for metadata that protects applications when metadata is published with errors. + /// SecurityTokenHandlers can be used when needs a when the security token is a JWT. + /// When using TokenHandlers, will be a when the security token is a JWT. + /// + public bool UseSecurityTokenHandlers { get; set; } } diff --git a/src/Security/Authentication/test/JwtBearerTests.cs b/src/Security/Authentication/test/JwtBearerTests.cs index c6c9268cca26..502afab2db02 100755 --- a/src/Security/Authentication/test/JwtBearerTests.cs +++ b/src/Security/Authentication/test/JwtBearerTests.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Authentication.JwtBearer; @@ -70,6 +71,7 @@ public async Task BearerTokenValidation() ValidAudience = "audience.contoso.com", IssuerSigningKey = key, }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenText; @@ -107,6 +109,7 @@ public async Task SaveBearerToken() ValidAudience = "audience.contoso.com", IssuerSigningKey = key, }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenText; @@ -121,9 +124,20 @@ public void MapInboundClaimsDefaultsToTrue() { var options = new JwtBearerOptions(); Assert.True(options.MapInboundClaims); + +#pragma warning disable CS0618 // Type or member is obsolete var jwtHandler = options.SecurityTokenValidators.First() as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete Assert.NotNull(jwtHandler); Assert.True(jwtHandler.MapInboundClaims); + + var tokenHandler = options.TokenHandlers.First() as JsonWebTokenHandler; + Assert.NotNull(tokenHandler); + Assert.True(tokenHandler.MapInboundClaims); + + options.MapInboundClaims = false; + Assert.False(jwtHandler.MapInboundClaims); + Assert.False(tokenHandler.MapInboundClaims); } [Fact] @@ -132,7 +146,9 @@ public void MapInboundClaimsCanBeSetToFalse() var options = new JwtBearerOptions(); options.MapInboundClaims = false; Assert.False(options.MapInboundClaims); +#pragma warning disable CS0618 // Type or member is obsolete var jwtHandler = options.SecurityTokenValidators.First() as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete Assert.NotNull(jwtHandler); Assert.False(jwtHandler.MapInboundClaims); } @@ -173,8 +189,11 @@ public async Task ThrowAtAuthenticationFailedEvent() return Task.FromResult(0); } }; + o.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete o.SecurityTokenValidators.Clear(); o.SecurityTokenValidators.Insert(0, new InvalidTokenValidator()); +#pragma warning restore CS0618 // Type or member is obsolete }, async (context, next) => { @@ -218,6 +237,7 @@ public async Task CustomHeaderReceived() return Task.FromResult(null); } }; + o.UseSecurityTokenValidators = true; }); using var server = host.GetTestServer(); @@ -229,7 +249,7 @@ public async Task CustomHeaderReceived() [Fact] public async Task NoHeaderReceived() { - using var host = await CreateHost(); + using var host = await CreateHost(o => o.UseSecurityTokenValidators = true); using var server = host.GetTestServer(); var response = await SendAsync(server, "http://example.com/oauth"); Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); @@ -238,7 +258,7 @@ public async Task NoHeaderReceived() [Fact] public async Task HeaderWithoutBearerReceived() { - using var host = await CreateHost(); + using var host = await CreateHost(o => o.UseSecurityTokenValidators = true); using var server = host.GetTestServer(); var response = await SendAsync(server, "http://example.com/oauth", "Token"); Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); @@ -247,7 +267,7 @@ public async Task HeaderWithoutBearerReceived() [Fact] public async Task UnrecognizedTokenReceived() { - using var host = await CreateHost(); + using var host = await CreateHost(o => o.UseSecurityTokenValidators = true); using var server = host.GetTestServer(); var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); @@ -259,8 +279,11 @@ public async Task InvalidTokenReceived() { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new InvalidTokenValidator()); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -283,8 +306,11 @@ public async Task ExceptionReportedInHeaderForAuthenticationFailures(Type errorT { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new InvalidTokenValidator(errorType)); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -304,8 +330,11 @@ public async Task ExceptionReportedInHeaderWithDetailsForAuthenticationFailures( { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new DetailedInvalidTokenValidator(errorType)); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -321,8 +350,11 @@ public async Task ExceptionNotReportedInHeaderForOtherFailures(Type errorType) { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new InvalidTokenValidator(errorType)); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -337,9 +369,12 @@ public async Task ExceptionsReportedInHeaderForMultipleAuthenticationFailures() { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new InvalidTokenValidator(typeof(SecurityTokenInvalidAudienceException))); options.SecurityTokenValidators.Add(new InvalidTokenValidator(typeof(SecurityTokenSignatureKeyNotFoundException))); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -372,6 +407,7 @@ public async Task ExceptionsReportedInHeaderExposesUserDefinedError(string error return Task.FromResult(0); } }; + options.UseSecurityTokenValidators = true; }); using var server = host.GetTestServer(); @@ -420,6 +456,7 @@ public async Task ExceptionNotReportedInHeaderWhenIncludeErrorDetailsIsFalse() using var host = await CreateHost(o => { o.IncludeErrorDetails = false; + o.UseSecurityTokenValidators = true; }); using var server = host.GetTestServer(); @@ -432,7 +469,7 @@ public async Task ExceptionNotReportedInHeaderWhenIncludeErrorDetailsIsFalse() [Fact] public async Task ExceptionNotReportedInHeaderWhenTokenWasMissing() { - using var host = await CreateHost(); + using var host = await CreateHost(o => o.UseSecurityTokenValidators = true); using var server = host.GetTestServer(); var response = await SendAsync(server, "http://example.com/oauth"); @@ -467,8 +504,11 @@ public async Task CustomTokenValidated() return Task.FromResult(null); } }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator(JwtBearerDefaults.AuthenticationScheme)); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -490,11 +530,14 @@ public async Task RetrievingTokenFromAlternateLocation() return Task.FromResult(null); } }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator("JWT", token => { Assert.Equal("CustomToken", token); })); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -508,6 +551,7 @@ public async Task EventOnMessageReceivedSkip_NoMoreEventsExecuted() { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; options.Events = new JwtBearerEvents() { OnMessageReceived = context => @@ -541,6 +585,7 @@ public async Task EventOnMessageReceivedReject_NoMoreEventsExecuted() { using var host = await CreateHost(options => { + options.UseSecurityTokenValidators = true; options.Events = new JwtBearerEvents() { OnMessageReceived = context => @@ -594,8 +639,11 @@ public async Task EventOnTokenValidatedSkip_NoMoreEventsExecuted() throw new NotImplementedException(); }, }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator("JWT")); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -626,8 +674,11 @@ public async Task EventOnTokenValidatedReject_NoMoreEventsExecuted() throw new NotImplementedException(); }, }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator("JWT")); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -660,8 +711,11 @@ public async Task EventOnAuthenticationFailedSkip_NoMoreEventsExecuted() throw new NotImplementedException(); }, }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator("JWT")); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -692,8 +746,11 @@ public async Task EventOnAuthenticationFailedReject_NoMoreEventsExecuted() throw new NotImplementedException(); }, }; + options.UseSecurityTokenValidators = true; +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenValidators.Clear(); options.SecurityTokenValidators.Add(new BlobTokenValidator("JWT")); +#pragma warning restore CS0618 // Type or member is obsolete }); using var server = host.GetTestServer(); @@ -718,6 +775,7 @@ public async Task EventOnChallengeSkip_ResponseNotModified() return Task.FromResult(0); }, }; + o.UseSecurityTokenValidators = true; }); using var server = host.GetTestServer(); @@ -740,6 +798,7 @@ public async Task EventOnForbidden_ResponseNotModified() ValidAudience = "audience.contoso.com", IssuerSigningKey = tokenData.key, }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenData.tokenText; using var server = host.GetTestServer(); @@ -766,6 +825,7 @@ public async Task EventOnForbiddenSkip_ResponseNotModified() return Task.FromResult(0); } }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenData.tokenText; using var server = host.GetTestServer(); @@ -793,6 +853,7 @@ public async Task EventOnForbidden_ResponseModified() return context.Response.WriteAsync("You Shall Not Pass"); } }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenData.tokenText; using var server = host.GetTestServer(); @@ -836,8 +897,8 @@ public async Task EventOnForbidden_ResponseForMultipleAuthenticationSchemas() .ConfigureServices(services => { services.AddAuthentication() - .AddJwtBearer("JwtAuthSchemaOne", o => { o.Events = jwtBearerEvents; }) - .AddJwtBearer("JwtAuthSchemaTwo", o => { o.Events = jwtBearerEvents; }); + .AddJwtBearer("JwtAuthSchemaOne", o => { o.Events = jwtBearerEvents; o.UseSecurityTokenValidators = true; }) + .AddJwtBearer("JwtAuthSchemaTwo", o => { o.Events = jwtBearerEvents; o.UseSecurityTokenValidators = true; }); })) .Build(); @@ -881,6 +942,7 @@ public async Task ExpirationAndIssuedSetOnAuthenticateResult() ValidAudience = "audience.contoso.com", IssuerSigningKey = key, }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenText; @@ -924,6 +986,7 @@ public async Task ExpirationAndIssuedNullWhenMinOrMaxValue() ValidAudience = "audience.contoso.com", IssuerSigningKey = key, }; + o.UseSecurityTokenValidators = true; }); var newBearerToken = "Bearer " + tokenText; @@ -956,7 +1019,7 @@ public void CanReadJwtBearerOptionsFromConfig() { o.AddScheme("Bearer", "Bearer"); }); - builder.AddJwtBearer("Bearer"); + builder.AddJwtBearer("Bearer", o => o.UseSecurityTokenValidators = true); RegisterAuth(builder, _ => { }); var sp = services.BuildServiceProvider(); @@ -994,7 +1057,7 @@ public void CanReadMultipleIssuersFromConfig() { o.AddScheme("Bearer", "Bearer"); }); - builder.AddJwtBearer("Bearer"); + builder.AddJwtBearer("Bearer", o => o.UseSecurityTokenValidators = true); RegisterAuth(builder, _ => { }); var sp = services.BuildServiceProvider(); diff --git a/src/Security/Authentication/test/JwtBearerTests_Handler.cs b/src/Security/Authentication/test/JwtBearerTests_Handler.cs new file mode 100644 index 000000000000..daf20680b687 --- /dev/null +++ b/src/Security/Authentication/test/JwtBearerTests_Handler.cs @@ -0,0 +1,1297 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.AspNetCore.Authentication.Tests; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.AspNetCore.Authentication.JwtBearer; + +public class JwtBearerTests_Handler : SharedAuthenticationTests +{ + protected override string DefaultScheme => JwtBearerDefaults.AuthenticationScheme; + protected override Type HandlerType => typeof(JwtBearerHandler); + protected override bool SupportsSignIn { get => false; } + protected override bool SupportsSignOut { get => false; } + + protected override void RegisterAuth(AuthenticationBuilder services, Action configure) + { + services.AddJwtBearer(o => + { + ConfigureDefaults(o); + configure.Invoke(o); + }); + } + + private void ConfigureDefaults(JwtBearerOptions o) + { + } + + [Fact] + public async Task BearerTokenValidation() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob") + }; + + var token = new JwtSecurityToken( + issuer: "issuer.contoso.com", + audience: "audience.contoso.com", + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + using var host = await CreateHost(o => + { + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = key, + }; + }); + + var newBearerToken = "Bearer " + tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", newBearerToken); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + } + + [Fact] + public async Task SaveBearerToken() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob") + }; + + var token = new JwtSecurityToken( + issuer: "issuer.contoso.com", + audience: "audience.contoso.com", + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + using var host = await CreateHost(o => + { + o.SaveToken = true; + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = key, + }; + }); + + var newBearerToken = "Bearer " + tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/token", newBearerToken); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(tokenText, await response.Response.Content.ReadAsStringAsync()); + } + + [Fact] + public void MapInboundClaimsDefaultsToTrue() + { + var options = new JwtBearerOptions(); + Assert.True(options.MapInboundClaims); + +#pragma warning disable CS0618 // Type or member is obsolete + var jwtHandler = options.SecurityTokenValidators.First() as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(jwtHandler); + Assert.True(jwtHandler.MapInboundClaims); + + var tokenHandler = options.TokenHandlers.First() as JsonWebTokenHandler; + Assert.NotNull(tokenHandler); + Assert.True(tokenHandler.MapInboundClaims); + + options.MapInboundClaims = false; + Assert.False(jwtHandler.MapInboundClaims); + Assert.False(tokenHandler.MapInboundClaims); + } + + [Fact] + public void MapInboundClaimsCanBeSetToFalse() + { + var options = new JwtBearerOptions(); + options.MapInboundClaims = false; + Assert.False(options.MapInboundClaims); +#pragma warning disable CS0618 // Type or member is obsolete + var jwtHandler = options.SecurityTokenValidators.First() as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(jwtHandler); + Assert.False(jwtHandler.MapInboundClaims); + } + + [Fact] + public async Task SignInThrows() + { + using var host = await CreateHost(); + using var server = host.GetTestServer(); + var transaction = await server.SendAsync("https://example.com/signIn"); + Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); + } + + [Fact] + public async Task SignOutThrows() + { + using var host = await CreateHost(); + using var server = host.GetTestServer(); + var transaction = await server.SendAsync("https://example.com/signOut"); + Assert.Equal(HttpStatusCode.OK, transaction.Response.StatusCode); + } + + [Fact] + public async Task ThrowAtAuthenticationFailedEvent() + { + using var host = await CreateHost(o => + { + o.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + context.Response.StatusCode = 401; + throw new Exception(); + }, + OnMessageReceived = context => + { + context.Token = "something"; + return Task.FromResult(0); + } + }; + o.TokenHandlers.Clear(); + o.TokenHandlers.Insert(0, new InvalidTokenValidator()); + }, + async (context, next) => + { + try + { + await next(); + Assert.False(true, "Expected exception is not thrown"); + } + catch (Exception) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("i got this"); + } + }); + + using var server = host.GetTestServer(); + var transaction = await server.SendAsync("https://example.com/signIn"); + + Assert.Equal(HttpStatusCode.Unauthorized, transaction.Response.StatusCode); + } + + [Fact] + public async Task CustomHeaderReceived() + { + using var host = await CreateHost(o => + { + o.Events = new JwtBearerEvents() + { + OnMessageReceived = context => + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob le Magnifique"), + new Claim(ClaimTypes.Email, "bob@contoso.com"), + new Claim(ClaimsIdentity.DefaultNameClaimType, "bob") + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + + return Task.FromResult(null); + } + }; + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "someHeader someblob"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Magnifique", response.ResponseText); + } + + [Fact] + public async Task NoHeaderReceived() + { + using var host = await CreateHost(); + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + } + + [Fact] + public async Task HeaderWithoutBearerReceived() + { + using var host = await CreateHost(); + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Token"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + } + + [Fact] + public async Task UnrecognizedTokenReceived() + { + using var host = await CreateHost(); + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task InvalidTokenReceived() + { + using var host = await CreateHost(options => + { + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new InvalidTokenValidator()); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("Bearer error=\"invalid_token\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(SecurityTokenInvalidAudienceException), "The audience '(null)' is invalid")] + [InlineData(typeof(SecurityTokenInvalidIssuerException), "The issuer '(null)' is invalid")] + [InlineData(typeof(SecurityTokenNoExpirationException), "The token has no expiration")] + [InlineData(typeof(SecurityTokenInvalidLifetimeException), "The token lifetime is invalid; NotBefore: '(null)', Expires: '(null)'")] + [InlineData(typeof(SecurityTokenNotYetValidException), "The token is not valid before '01/01/0001 00:00:00'")] + [InlineData(typeof(SecurityTokenExpiredException), "The token expired at '01/01/0001 00:00:00'")] + [InlineData(typeof(SecurityTokenInvalidSignatureException), "The signature is invalid")] + [InlineData(typeof(SecurityTokenSignatureKeyNotFoundException), "The signature key was not found")] + public async Task ExceptionReportedInHeaderForAuthenticationFailures(Type errorType, string message) + { + using var host = await CreateHost(options => + { + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new InvalidTokenValidator(errorType)); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal($"Bearer error=\"invalid_token\", error_description=\"{message}\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(SecurityTokenInvalidAudienceException), "The audience 'Bad Audience' is invalid")] + [InlineData(typeof(SecurityTokenInvalidIssuerException), "The issuer 'Bad Issuer' is invalid")] + [InlineData(typeof(SecurityTokenInvalidLifetimeException), "The token lifetime is invalid; NotBefore: '01/15/2001 00:00:00', Expires: '02/20/2000 00:00:00'")] + [InlineData(typeof(SecurityTokenNotYetValidException), "The token is not valid before '01/15/2045 00:00:00'")] + [InlineData(typeof(SecurityTokenExpiredException), "The token expired at '02/20/2000 00:00:00'")] + public async Task ExceptionReportedInHeaderWithDetailsForAuthenticationFailures(Type errorType, string message) + { + using var host = await CreateHost(options => + { + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new DetailedInvalidTokenValidator(errorType)); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal($"Bearer error=\"invalid_token\", error_description=\"{message}\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData(typeof(ArgumentException))] + public async Task ExceptionNotReportedInHeaderForOtherFailures(Type errorType) + { + using var host = await CreateHost(options => + { + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new InvalidTokenValidator(errorType)); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("Bearer error=\"invalid_token\"", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task ExceptionsReportedInHeaderForMultipleAuthenticationFailures() + { + using var host = await CreateHost(options => + { + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new InvalidTokenValidator(typeof(SecurityTokenInvalidAudienceException))); + options.TokenHandlers.Add(new InvalidTokenValidator(typeof(SecurityTokenSignatureKeyNotFoundException))); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("Bearer error=\"invalid_token\", error_description=\"The audience '(null)' is invalid; The signature key was not found\"", + response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Theory] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", null, null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, null, "custom_uri")] + public async Task ExceptionsReportedInHeaderExposesUserDefinedError(string error, string description, string uri) + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents + { + OnChallenge = context => + { + context.Error = error; + context.ErrorDescription = description; + context.ErrorUri = uri; + + return Task.FromResult(0); + } + }; + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("", response.ResponseText); + + var builder = new StringBuilder(JwtBearerDefaults.AuthenticationScheme); + + if (!string.IsNullOrEmpty(error)) + { + builder.Append(" error=\""); + builder.Append(error); + builder.Append("\""); + } + if (!string.IsNullOrEmpty(description)) + { + if (!string.IsNullOrEmpty(error)) + { + builder.Append(","); + } + + builder.Append(" error_description=\""); + builder.Append(description); + builder.Append('\"'); + } + if (!string.IsNullOrEmpty(uri)) + { + if (!string.IsNullOrEmpty(error) || + !string.IsNullOrEmpty(description)) + { + builder.Append(","); + } + + builder.Append(" error_uri=\""); + builder.Append(uri); + builder.Append('\"'); + } + + Assert.Equal(builder.ToString(), response.Response.Headers.WwwAuthenticate.First().ToString()); + } + + [Fact] + public async Task ExceptionNotReportedInHeaderWhenIncludeErrorDetailsIsFalse() + { + using var host = await CreateHost(o => + { + o.IncludeErrorDetails = false; + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("Bearer", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task ExceptionNotReportedInHeaderWhenTokenWasMissing() + { + using var host = await CreateHost(); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth"); + Assert.Equal(HttpStatusCode.Unauthorized, response.Response.StatusCode); + Assert.Equal("Bearer", response.Response.Headers.WwwAuthenticate.First().ToString()); + Assert.Equal("", response.ResponseText); + } + + [Fact] + public async Task CustomTokenValidated() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnTokenValidated = context => + { + // Retrieve the NameIdentifier claim from the identity + // returned by the custom security token validator. + var identity = (ClaimsIdentity)context.Principal.Identity; + var identifier = identity.FindFirst(ClaimTypes.NameIdentifier); + + Assert.Equal("Bob le Tout Puissant", identifier.Value); + + // Remove the existing NameIdentifier claim and replace it + // with a new one containing a different value. + identity.RemoveClaim(identifier); + // Make sure to use a different name identifier + // than the one defined by BlobTokenValidator. + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "Bob le Magnifique")); + + return Task.FromResult(null); + } + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator(JwtBearerDefaults.AuthenticationScheme)); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer someblob"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Magnifique", response.ResponseText); + } + + [Fact] + public async Task RetrievingTokenFromAlternateLocation() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnMessageReceived = context => + { + context.Token = "CustomToken"; + return Task.FromResult(null); + } + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator("JWT", token => + { + Assert.Equal("CustomToken", token); + })); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/oauth", "Bearer Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal("Bob le Tout Puissant", response.ResponseText); + } + + [Fact] + public async Task EventOnMessageReceivedSkip_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnMessageReceived = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnTokenValidated = context => + { + throw new NotImplementedException(); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnMessageReceivedReject_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnMessageReceived = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnTokenValidated = context => + { + throw new NotImplementedException(); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + }); + + using var server = host.GetTestServer(); + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnTokenValidatedSkip_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnTokenValidated = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator("JWT")); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnTokenValidatedReject_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnTokenValidated = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnAuthenticationFailed = context => + { + throw new NotImplementedException(context.Exception.ToString()); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator("JWT")); + }); + + using var server = host.GetTestServer(); + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnAuthenticationFailedSkip_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnTokenValidated = context => + { + throw new Exception("Test Exception"); + }, + OnAuthenticationFailed = context => + { + context.NoResult(); + return Task.FromResult(0); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator("JWT")); + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnAuthenticationFailedReject_NoMoreEventsExecuted() + { + using var host = await CreateHost(options => + { + options.Events = new JwtBearerEvents() + { + OnTokenValidated = context => + { + throw new Exception("Test Exception"); + }, + OnAuthenticationFailed = context => + { + context.Fail("Authentication was aborted from user code."); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }, + OnChallenge = context => + { + throw new NotImplementedException(); + }, + }; + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new BlobTokenValidator("JWT")); + }); + + using var server = host.GetTestServer(); + var exception = await Assert.ThrowsAsync(delegate + { + return SendAsync(server, "http://example.com/checkforerrors", "Bearer Token"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + } + + [Fact] + public async Task EventOnChallengeSkip_ResponseNotModified() + { + using var host = await CreateHost(o => + { + o.Events = new JwtBearerEvents() + { + OnChallenge = context => + { + context.HandleResponse(); + return Task.FromResult(0); + }, + }; + }); + + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/unauthorized", "Bearer Token"); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + Assert.Empty(response.Response.Headers.WwwAuthenticate); + Assert.Equal(string.Empty, response.ResponseText); + } + + [Fact] + public async Task EventOnForbidden_ResponseNotModified() + { + var tokenData = CreateStandardTokenAndKey(); + + using var host = await CreateHost(o => + { + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = tokenData.key, + }; + }); + var newBearerToken = "Bearer " + tokenData.tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/forbidden", newBearerToken); + Assert.Equal(HttpStatusCode.Forbidden, response.Response.StatusCode); + } + + [Fact] + public async Task EventOnForbiddenSkip_ResponseNotModified() + { + var tokenData = CreateStandardTokenAndKey(); + using var host = await CreateHost(o => + { + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = tokenData.key, + }; + o.Events = new JwtBearerEvents() + { + OnForbidden = context => + { + return Task.FromResult(0); + } + }; + }); + var newBearerToken = "Bearer " + tokenData.tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/forbidden", newBearerToken); + Assert.Equal(HttpStatusCode.Forbidden, response.Response.StatusCode); + } + + [Fact] + public async Task EventOnForbidden_ResponseModified() + { + var tokenData = CreateStandardTokenAndKey(); + using var host = await CreateHost(o => + { + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = tokenData.key, + }; + o.Events = new JwtBearerEvents() + { + OnForbidden = context => + { + context.Response.StatusCode = 418; + return context.Response.WriteAsync("You Shall Not Pass"); + } + }; + }); + var newBearerToken = "Bearer " + tokenData.tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/forbidden", newBearerToken); + Assert.Equal(418, (int)response.Response.StatusCode); + Assert.Equal("You Shall Not Pass", await response.Response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task EventOnForbidden_ResponseForMultipleAuthenticationSchemas() + { + var onForbiddenCallCount = 0; + var jwtBearerEvents = new JwtBearerEvents() + { + OnForbidden = context => + { + onForbiddenCallCount++; + + if (!context.Response.HasStarted) + { + context.Response.StatusCode = 418; + return context.Response.WriteAsync("You Shall Not Pass"); + } + return Task.CompletedTask; + } + }; + + using var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .Configure(app => + { + app.UseAuthentication(); + app.Run(async (context) => + { + // Simulate Forbidden By Multiple Authentication Schemas + await context.ForbidAsync("JwtAuthSchemaOne"); + await context.ForbidAsync("JwtAuthSchemaTwo"); + }); + }) + .ConfigureServices(services => + { + services.AddAuthentication() + .AddJwtBearer("JwtAuthSchemaOne", o => { o.Events = jwtBearerEvents; }) + .AddJwtBearer("JwtAuthSchemaTwo", o => { o.Events = jwtBearerEvents; }); + })) + .Build(); + + await host.StartAsync(); + + using var server = host.GetTestServer(); + var response = await server.CreateClient().SendAsync(new HttpRequestMessage(HttpMethod.Get, string.Empty)); + + Assert.Equal(418, (int)response.StatusCode); + Assert.Equal("You Shall Not Pass", await response.Content.ReadAsStringAsync()); + Assert.Equal(2, onForbiddenCallCount); + } + + [Fact] + public async Task ExpirationAndIssuedSetOnAuthenticateResult() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob") + }; + + var token = new JwtSecurityToken( + issuer: "issuer.contoso.com", + audience: "audience.contoso.com", + claims: claims, + notBefore: DateTime.Now.AddMinutes(-10), + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + using var host = await CreateHost(o => + { + o.SaveToken = true; + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = key, + }; + }); + + var newBearerToken = "Bearer " + tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/expiration", newBearerToken); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + var responseBody = await response.Response.Content.ReadAsStringAsync(); + using var dom = JsonDocument.Parse(responseBody); + Assert.NotEqual(DateTimeOffset.MinValue, token.ValidTo); + Assert.NotEqual(DateTimeOffset.MinValue, token.ValidFrom); + Assert.Equal(token.ValidTo, dom.RootElement.GetProperty("expires").GetDateTimeOffset()); + Assert.Equal(token.ValidFrom, dom.RootElement.GetProperty("issued").GetDateTimeOffset()); + } + + [Fact] + public async Task ExpirationAndIssuedWhenMinOrMaxValue() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob") + }; + + var token = new JwtSecurityToken( + issuer: "issuer.contoso.com", + audience: "audience.contoso.com", + claims: claims, + expires: DateTime.MaxValue, + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + + using var host = await CreateHost(o => + { + o.SaveToken = true; + o.TokenValidationParameters = new TokenValidationParameters() + { + ValidIssuer = "issuer.contoso.com", + ValidAudience = "audience.contoso.com", + IssuerSigningKey = key, + }; + }); + + var newBearerToken = "Bearer " + tokenText; + using var server = host.GetTestServer(); + var response = await SendAsync(server, "http://example.com/expiration", newBearerToken); + Assert.Equal(HttpStatusCode.OK, response.Response.StatusCode); + var responseBody = await response.Response.Content.ReadAsStringAsync(); + using var dom = JsonDocument.Parse(responseBody); + Assert.Equal(JsonValueKind.Null, dom.RootElement.GetProperty("issued").ValueKind); + + var expiresElement = dom.RootElement.GetProperty("expires"); + Assert.Equal(JsonValueKind.String, expiresElement.ValueKind); + + var elementValue = DateTime.Parse(expiresElement.GetString()); + var elementValueUtc = elementValue.ToUniversalTime(); + // roundtrip DateTime.MaxValue through parsing because it is lossy and we + // need equivalent values to compare against. + var max = DateTime.Parse(DateTime.MaxValue.ToString()); + + Assert.Equal(max, elementValueUtc); + } + + [Fact] + public void CanReadJwtBearerOptionsFromConfig() + { + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:Bearer:ValidIssuer", "dotnet-user-jwts"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidAudiences:0", "http://localhost:5000"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidAudiences:1", "https://localhost:5001"), + new KeyValuePair("Authentication:Schemes:Bearer:BackchannelTimeout", "00:01:00"), + new KeyValuePair("Authentication:Schemes:Bearer:RequireHttpsMetadata", "false"), + new KeyValuePair("Authentication:Schemes:Bearer:SaveToken", "True"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(o => + { + o.AddScheme("Bearer", "Bearer"); + }); + builder.AddJwtBearer("Bearer"); + RegisterAuth(builder, _ => { }); + var sp = services.BuildServiceProvider(); + + // Assert + var jwtBearerOptions = sp.GetRequiredService>().Get(JwtBearerDefaults.AuthenticationScheme); + Assert.Equal(jwtBearerOptions.TokenValidationParameters.ValidIssuers, new[] { "dotnet-user-jwts" }); + Assert.Equal(jwtBearerOptions.TokenValidationParameters.ValidAudiences, new[] { "http://localhost:5000", "https://localhost:5001" }); + Assert.Equal(jwtBearerOptions.BackchannelTimeout, TimeSpan.FromSeconds(60)); + Assert.False(jwtBearerOptions.RequireHttpsMetadata); + Assert.True(jwtBearerOptions.SaveToken); + Assert.True(jwtBearerOptions.MapInboundClaims); // Assert default values are respected + } + + [Fact] + public void CanReadMultipleIssuersFromConfig() + { + var services = new ServiceCollection().AddLogging(); + var firstKey = "qPG6tDtfxFYZifHW3sEueQ=="; + var secondKey = "6JPzXj6aOPdojlZdeLshaA=="; + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:Bearer:ValidIssuers:0", "dotnet-user-jwts"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidIssuers:1", "dotnet-user-jwts-2"), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:0:Issuer", "dotnet-user-jwts"), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:0:Value", firstKey), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:0:Length", "32"), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:1:Issuer", "dotnet-user-jwts-2"), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:1:Value", secondKey), + new KeyValuePair("Authentication:Schemes:Bearer:SigningKeys:1:Length", "32"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(o => + { + o.AddScheme("Bearer", "Bearer"); + }); + builder.AddJwtBearer("Bearer"); + RegisterAuth(builder, _ => { }); + var sp = services.BuildServiceProvider(); + + // Assert + var jwtBearerOptions = sp.GetRequiredService>().Get(JwtBearerDefaults.AuthenticationScheme); + Assert.Equal(2, jwtBearerOptions.TokenValidationParameters.IssuerSigningKeys.Count()); + Assert.Equal(firstKey, Convert.ToBase64String(jwtBearerOptions.TokenValidationParameters.IssuerSigningKeys.OfType().FirstOrDefault()?.Key)); + Assert.Equal(secondKey, Convert.ToBase64String(jwtBearerOptions.TokenValidationParameters.IssuerSigningKeys.OfType().LastOrDefault()?.Key)); + } + + class InvalidTokenValidator : TokenHandler + { + public InvalidTokenValidator() + { + ExceptionType = typeof(SecurityTokenException); + } + + public InvalidTokenValidator(Type exceptionType) + { + ExceptionType = exceptionType; + } + + public Type ExceptionType { get; set; } + + public override SecurityToken ReadToken(string token) + { + return new JsonWebToken(token); + } + + public override Task ValidateTokenAsync(string token, TokenValidationParameters validationParameters) + { + var constructor = ExceptionType.GetTypeInfo().GetConstructor(new[] { typeof(string) }); + var exception = (Exception)constructor.Invoke(new[] { ExceptionType.Name }); + throw exception; + } + } + + class DetailedInvalidTokenValidator : TokenHandler + { + public DetailedInvalidTokenValidator() + { + ExceptionType = typeof(SecurityTokenException); + } + + public DetailedInvalidTokenValidator(Type exceptionType) + { + ExceptionType = exceptionType; + } + + public Type ExceptionType { get; set; } + + public override SecurityToken ReadToken(string token) + { + return new JsonWebToken(token); + } + + public override Task ValidateTokenAsync(string token, TokenValidationParameters validationParameters) + { + if (ExceptionType == typeof(SecurityTokenInvalidAudienceException)) + { + throw new SecurityTokenInvalidAudienceException("SecurityTokenInvalidAudienceException") { InvalidAudience = "Bad Audience" }; + } + if (ExceptionType == typeof(SecurityTokenInvalidIssuerException)) + { + throw new SecurityTokenInvalidIssuerException("SecurityTokenInvalidIssuerException") { InvalidIssuer = "Bad Issuer" }; + } + if (ExceptionType == typeof(SecurityTokenInvalidLifetimeException)) + { + throw new SecurityTokenInvalidLifetimeException("SecurityTokenInvalidLifetimeException") + { + NotBefore = new DateTime(2001, 1, 15), + Expires = new DateTime(2000, 2, 20), + }; + } + if (ExceptionType == typeof(SecurityTokenNotYetValidException)) + { + throw new SecurityTokenNotYetValidException("SecurityTokenNotYetValidException") + { + NotBefore = new DateTime(2045, 1, 15), + }; + } + if (ExceptionType == typeof(SecurityTokenExpiredException)) + { + throw new SecurityTokenExpiredException("SecurityTokenExpiredException") + { + Expires = new DateTime(2000, 2, 20), + }; + } + else + { + throw new NotImplementedException(ExceptionType.Name); + } + } + } + + class BlobTokenValidator : TokenHandler + { + private readonly Action _tokenValidator; + + public BlobTokenValidator(string authenticationScheme) + { + AuthenticationScheme = authenticationScheme; + } + + public BlobTokenValidator(string authenticationScheme, Action tokenValidator) + { + AuthenticationScheme = authenticationScheme; + _tokenValidator = tokenValidator; + } + + public string AuthenticationScheme { get; } + + public override SecurityToken ReadToken(string token) + { + return new JsonWebToken(token); + } + + public override Task ValidateTokenAsync(string token, TokenValidationParameters validationParameters) + { + var validatedToken = new TestSecurityToken(); + _tokenValidator?.Invoke(token); + + var claims = new[] + { + // Make sure to use a different name identifier + // than the one defined by CustomTokenValidated. + new Claim(ClaimTypes.NameIdentifier, "Bob le Tout Puissant"), + new Claim(ClaimTypes.Email, "bob@contoso.com"), + new Claim(ClaimsIdentity.DefaultNameClaimType, "bob"), + }; + + return Task.FromResult(new TokenValidationResult + { + ClaimsIdentity = new ClaimsIdentity(claims, AuthenticationScheme), + SecurityToken = validatedToken, + IsValid = true + }); + } + } + + private static async Task CreateHost(Action options = null, Func, Task> handlerBeforeAuth = null) + { + var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .Configure(app => + { + if (handlerBeforeAuth != null) + { + app.Use(handlerBeforeAuth); + } + + app.UseAuthentication(); + app.Use(async (context, next) => + { + if (context.Request.Path == new PathString("/checkforerrors")) + { + var result = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); // this used to be "Automatic" + if (result.Failure != null) + { + throw new Exception("Failed to authenticate", result.Failure); + } + return; + } + else if (context.Request.Path == new PathString("/oauth")) + { + if (context.User == null || + context.User.Identity == null || + !context.User.Identity.IsAuthenticated) + { + context.Response.StatusCode = 401; + // REVIEW: no more automatic challenge + await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); + return; + } + + var identifier = context.User.FindFirst(ClaimTypes.NameIdentifier); + if (identifier == null) + { + context.Response.StatusCode = 500; + return; + } + + await context.Response.WriteAsync(identifier.Value); + } + else if (context.Request.Path == new PathString("/token")) + { + var token = await context.GetTokenAsync("access_token"); + await context.Response.WriteAsync(token); + } + else if (context.Request.Path == new PathString("/unauthorized")) + { + // Simulate Authorization failure + var result = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); + } + else if (context.Request.Path == new PathString("/forbidden")) + { + // Simulate Forbidden + await context.ForbidAsync(JwtBearerDefaults.AuthenticationScheme); + } + else if (context.Request.Path == new PathString("/signIn")) + { + await Assert.ThrowsAsync(() => context.SignInAsync(JwtBearerDefaults.AuthenticationScheme, new ClaimsPrincipal())); + } + else if (context.Request.Path == new PathString("/signOut")) + { + await Assert.ThrowsAsync(() => context.SignOutAsync(JwtBearerDefaults.AuthenticationScheme)); + } + else if (context.Request.Path == new PathString("/expiration")) + { + var authenticationResult = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + await context.Response.WriteAsJsonAsync( + new { Expires = authenticationResult.Properties?.ExpiresUtc, Issued = authenticationResult.Properties?.IssuedUtc }); + } + else + { + await next(context); + } + }); + }) + .ConfigureServices(services => services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options))) + .Build(); + + await host.StartAsync(); + return host; + } + + // TODO: see if we can share the TestExtensions SendAsync method (only diff is auth header) + private static async Task SendAsync(TestServer server, string uri, string authorizationHeader = null) + { + var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (!string.IsNullOrEmpty(authorizationHeader)) + { + request.Headers.Add("Authorization", authorizationHeader); + } + + var transaction = new Transaction + { + Request = request, + Response = await server.CreateClient().SendAsync(request), + }; + + transaction.ResponseText = await transaction.Response.Content.ReadAsStringAsync(); + + if (transaction.Response.Content != null && + transaction.Response.Content.Headers.ContentType != null && + transaction.Response.Content.Headers.ContentType.MediaType == "text/xml") + { + transaction.ResponseElement = XElement.Parse(transaction.ResponseText); + } + + return transaction; + } + + private static (string tokenText, SymmetricSecurityKey key) CreateStandardTokenAndKey() + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('a', 128))); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob") + }; + + var token = new JwtSecurityToken( + issuer: "issuer.contoso.com", + audience: "audience.contoso.com", + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds); + + var tokenText = new JwtSecurityTokenHandler().WriteToken(token); + return (tokenText, key); + } +} diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests.cs index 3da118f7e03e..b54800821e43 100644 --- a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests.cs +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; @@ -1285,7 +1286,10 @@ private TestServer CreateServer(OpenIdConnectEvents events, RequestDelegate appC EndSessionEndpoint = "http://testhost/end" }; o.StateDataFormat = new TestStateDataFormat(); +#pragma warning disable CS0618 // Type or member is obsolete o.SecurityTokenValidator = new TestTokenValidator(); +#pragma warning restore CS0618 // Type or member is obsolete + o.UseSecurityTokenValidator = true; o.ProtocolValidator = new TestProtocolValidator(); o.BackchannelHttpHandler = new TestBackchannel(); }); diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests_Handler.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests_Handler.cs new file mode 100644 index 000000000000..7279b02a724a --- /dev/null +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectEventTests_Handler.cs @@ -0,0 +1,1404 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Authentication.Test.OpenIdConnect; + +public class OpenIdConnectEventTests_Handlers +{ + private readonly RequestDelegate AppWritePath = context => context.Response.WriteAsync(context.Request.Path); + private readonly RequestDelegate AppNotImpl = context => { throw new NotImplementedException("App"); }; + + [Fact] + public async Task OnMessageReceived_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + }; + events.OnMessageReceived = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", ""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnMessageReceived_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectRemoteFailure = true, + }; + events.OnMessageReceived = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", ""); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnMessageReceived_Handled_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + }; + events.OnMessageReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidated_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + }; + events.OnTokenValidated = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidated_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectRemoteFailure = true, + }; + events.OnTokenValidated = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidated_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + }; + events.OnTokenValidated = context => + { + context.HandleResponse(); + context.Principal = null; + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidated_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectTicketReceived = true, + }; + events.OnTokenValidated = context => + { + context.HandleResponse(); + context.Principal = null; + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + events.OnTokenValidated = context => + { + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthorizationCodeReceived_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + }; + events.OnAuthorizationCodeReceived = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthorizationCodeReceived_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectRemoteFailure = true, + }; + events.OnAuthorizationCodeReceived = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthorizationCodeReceived_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + }; + events.OnAuthorizationCodeReceived = context => + { + context.HandleResponse(); + context.Principal = null; + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthorizationCodeReceived_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTicketReceived = true, + }; + events.OnAuthorizationCodeReceived = context => + { + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenResponseReceived_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + }; + events.OnTokenResponseReceived = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenResponseReceived_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectRemoteFailure = true, + }; + events.OnTokenResponseReceived = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenResponseReceived_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + }; + events.OnTokenResponseReceived = context => + { + context.Principal = null; + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenResponseReceived_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectTicketReceived = true, + }; + events.OnTokenResponseReceived = context => + { + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidatedBackchannel_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + }; + events.OnTokenValidated = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidatedBackchannel_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectRemoteFailure = true, + }; + events.OnTokenValidated = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "state=protected_state&code=my_code"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidatedBackchannel_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + }; + events.OnTokenValidated = context => + { + context.Principal = null; + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTokenValidatedBackchannel_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectTicketReceived = true, + }; + events.OnTokenValidated = context => + { + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnUserInformationReceived_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + }; + events.OnUserInformationReceived = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnUserInformationReceived_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectRemoteFailure = true, + }; + events.OnUserInformationReceived = context => + { + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnUserInformationReceived_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + }; + events.OnUserInformationReceived = context => + { + context.Principal = null; + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnUserInformationReceived_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectTicketReceived = true, + }; + events.OnUserInformationReceived = context => + { + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthenticationFailed_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnAuthenticationFailed = context => + { + Assert.Equal("TestException", context.Exception.Message); + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthenticationFailed_Fail_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + ExpectRemoteFailure = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnAuthenticationFailed = context => + { + Assert.Equal("TestException", context.Exception.Message); + context.Fail("Authentication was aborted from user code."); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var exception = await Assert.ThrowsAsync(delegate + { + return PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + }); + + Assert.Equal("Authentication was aborted from user code.", exception.InnerException.Message); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthenticationFailed_HandledWithoutTicket_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnAuthenticationFailed = context => + { + Assert.Equal("TestException", context.Exception.Message); + Assert.Null(context.Principal); + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAuthenticationFailed_HandledWithTicket_SkipToTicketReceived() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + ExpectTicketReceived = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnAuthenticationFailed = context => + { + Assert.Equal("TestException", context.Exception.Message); + Assert.Null(context.Principal); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "Bob le Magnifique"), + new Claim(ClaimTypes.Email, "bob@contoso.com"), + new Claim(ClaimsIdentity.DefaultNameClaimType, "bob") + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + return Task.FromResult(0); + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAccessDenied_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectAccessDenied = true + }; + events.OnAccessDenied = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "error=access_denied&state=protected_state"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnAccessDenied_Handled_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectAccessDenied = true + }; + events.OnAccessDenied = context => + { + Assert.Equal("testvalue", context.Properties.Items["testkey"]); + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "error=access_denied&state=protected_state"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRemoteFailure_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + ExpectRemoteFailure = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnAuthenticationFailed = context => + { + Assert.Equal("TestException", context.Exception.Message); + return Task.FromResult(0); + }; + events.OnRemoteFailure = context => + { + Assert.Equal("TestException", context.Failure.Message); + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRemoteFailure_Handled_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectAuthenticationFailed = true, + ExpectRemoteFailure = true, + }; + events.OnUserInformationReceived = context => + { + throw new NotImplementedException("TestException"); + }; + events.OnRemoteFailure = context => + { + Assert.Equal("TestException", context.Failure.Message); + Assert.Equal("testvalue", context.Properties.Items["testkey"]); + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTicketReceived_Skip_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectTicketReceived = true, + }; + events.OnTicketReceived = context => + { + context.SkipHandler(); + return Task.FromResult(0); + }; + var server = CreateServer(events, AppWritePath); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("/signin-oidc", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnTicketReceived_Handled_NoMoreEventsRun() + { + var events = new ExpectedOidcEvents() + { + ExpectMessageReceived = true, + ExpectTokenValidated = true, + ExpectAuthorizationCodeReceived = true, + ExpectTokenResponseReceived = true, + ExpectUserInfoReceived = true, + ExpectTicketReceived = true, + }; + events.OnTicketReceived = context => + { + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.FromResult(0); + }; + var server = CreateServer(events, AppNotImpl); + + var response = await PostAsync(server, "signin-oidc", "id_token=my_id_token&state=protected_state&code=my_code"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRedirectToIdentityProviderForSignOut_Invoked() + { + var events = new ExpectedOidcEvents() + { + ExpectRedirectForSignOut = true, + }; + var server = CreateServer(events, + context => + { + return context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); + }); + + var client = server.CreateClient(); + var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("http://testhost/end", response.Headers.Location.GetLeftPart(UriPartial.Path)); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRedirectToIdentityProviderForSignOut_Handled_RedirectNotInvoked() + { + var events = new ExpectedOidcEvents() + { + ExpectRedirectForSignOut = true, + }; + events.OnRedirectToIdentityProviderForSignOut = context => + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + context.HandleResponse(); + return Task.CompletedTask; + }; + var server = CreateServer(events, + context => + { + return context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); + }); + + var client = server.CreateClient(); + var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Null(response.Headers.Location); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRemoteSignOut_Invoked() + { + var events = new ExpectedOidcEvents() + { + ExpectRemoteSignOut = true, + }; + var server = CreateServer(events, AppNotImpl); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-oidc"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + events.ValidateExpectations(); + Assert.True(response.Headers.TryGetValues(HeaderNames.SetCookie, out var values)); + Assert.True(SetCookieHeaderValue.TryParseStrictList(values.ToList(), out var parsedValues)); + Assert.Equal(1, parsedValues.Count); + Assert.True(StringSegment.IsNullOrEmpty(parsedValues.Single().Value)); + } + + [Fact] + public async Task OnRemoteSignOut_Handled_NoSignout() + { + var events = new ExpectedOidcEvents() + { + ExpectRemoteSignOut = true, + }; + events.OnRemoteSignOut = context => + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + context.HandleResponse(); + return Task.CompletedTask; + }; + var server = CreateServer(events, AppNotImpl); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-oidc"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + events.ValidateExpectations(); + Assert.False(response.Headers.TryGetValues(HeaderNames.SetCookie, out var values)); + } + + [Fact] + public async Task OnRemoteSignOut_Skip_NoSignout() + { + var events = new ExpectedOidcEvents() + { + ExpectRemoteSignOut = true, + }; + events.OnRemoteSignOut = context => + { + context.SkipHandler(); + return Task.CompletedTask; + }; + var server = CreateServer(events, context => + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.CompletedTask; + }); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-oidc"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + events.ValidateExpectations(); + Assert.False(response.Headers.TryGetValues(HeaderNames.SetCookie, out var values)); + } + + [Fact] + public async Task OnRedirectToSignedOutRedirectUri_Invoked() + { + var events = new ExpectedOidcEvents() + { + ExpectRedirectToSignedOut = true, + }; + var server = CreateServer(events, AppNotImpl); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-callback-oidc?state=protected_state"); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Equal("http://testhost/redirect", response.Headers.Location.AbsoluteUri); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRedirectToSignedOutRedirectUri_Handled_NoRedirect() + { + var events = new ExpectedOidcEvents() + { + ExpectRedirectToSignedOut = true, + }; + events.OnSignedOutCallbackRedirect = context => + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + context.HandleResponse(); + return Task.CompletedTask; + }; + var server = CreateServer(events, AppNotImpl); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-callback-oidc?state=protected_state"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Null(response.Headers.Location); + events.ValidateExpectations(); + } + + [Fact] + public async Task OnRedirectToSignedOutRedirectUri_Skipped_NoRedirect() + { + var events = new ExpectedOidcEvents() + { + ExpectRedirectToSignedOut = true, + }; + events.OnSignedOutCallbackRedirect = context => + { + context.SkipHandler(); + return Task.CompletedTask; + }; + var server = CreateServer(events, + context => + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + return Task.CompletedTask; + }); + + var client = server.CreateClient(); + var response = await client.GetAsync("/signout-callback-oidc?state=protected_state"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Null(response.Headers.Location); + events.ValidateExpectations(); + } + + private class ExpectedOidcEvents : OpenIdConnectEvents + { + public bool ExpectMessageReceived { get; set; } + public bool InvokedMessageReceived { get; set; } + + public bool ExpectTokenValidated { get; set; } + public bool InvokedTokenValidated { get; set; } + + public bool ExpectAccessDenied { get; set; } + public bool InvokedAccessDenied { get; set; } + + public bool ExpectRemoteFailure { get; set; } + public bool InvokedRemoteFailure { get; set; } + + public bool ExpectTicketReceived { get; set; } + public bool InvokedTicketReceived { get; set; } + + public bool ExpectAuthorizationCodeReceived { get; set; } + public bool InvokedAuthorizationCodeReceived { get; set; } + + public bool ExpectTokenResponseReceived { get; set; } + public bool InvokedTokenResponseReceived { get; set; } + + public bool ExpectUserInfoReceived { get; set; } + public bool InvokedUserInfoReceived { get; set; } + + public bool ExpectAuthenticationFailed { get; set; } + public bool InvokeAuthenticationFailed { get; set; } + + public bool ExpectRedirectForSignOut { get; set; } + public bool InvokedRedirectForSignOut { get; set; } + + public bool ExpectRemoteSignOut { get; set; } + public bool InvokedRemoteSignOut { get; set; } + + public bool ExpectRedirectToSignedOut { get; set; } + public bool InvokedRedirectToSignedOut { get; set; } + + public override Task MessageReceived(MessageReceivedContext context) + { + InvokedMessageReceived = true; + return base.MessageReceived(context); + } + + public override Task TokenValidated(TokenValidatedContext context) + { + InvokedTokenValidated = true; + return base.TokenValidated(context); + } + + public override Task AuthorizationCodeReceived(AuthorizationCodeReceivedContext context) + { + InvokedAuthorizationCodeReceived = true; + return base.AuthorizationCodeReceived(context); + } + + public override Task TokenResponseReceived(TokenResponseReceivedContext context) + { + InvokedTokenResponseReceived = true; + return base.TokenResponseReceived(context); + } + + public override Task UserInformationReceived(UserInformationReceivedContext context) + { + InvokedUserInfoReceived = true; + return base.UserInformationReceived(context); + } + + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + InvokeAuthenticationFailed = true; + return base.AuthenticationFailed(context); + } + + public override Task TicketReceived(TicketReceivedContext context) + { + InvokedTicketReceived = true; + return base.TicketReceived(context); + } + + public override Task AccessDenied(AccessDeniedContext context) + { + InvokedAccessDenied = true; + return base.AccessDenied(context); + } + + public override Task RemoteFailure(RemoteFailureContext context) + { + InvokedRemoteFailure = true; + return base.RemoteFailure(context); + } + + public override Task RedirectToIdentityProviderForSignOut(RedirectContext context) + { + InvokedRedirectForSignOut = true; + return base.RedirectToIdentityProviderForSignOut(context); + } + + public override Task RemoteSignOut(RemoteSignOutContext context) + { + InvokedRemoteSignOut = true; + return base.RemoteSignOut(context); + } + + public override Task SignedOutCallbackRedirect(RemoteSignOutContext context) + { + InvokedRedirectToSignedOut = true; + return base.SignedOutCallbackRedirect(context); + } + + public void ValidateExpectations() + { + Assert.Equal(ExpectMessageReceived, InvokedMessageReceived); + Assert.Equal(ExpectTokenValidated, InvokedTokenValidated); + Assert.Equal(ExpectAuthorizationCodeReceived, InvokedAuthorizationCodeReceived); + Assert.Equal(ExpectTokenResponseReceived, InvokedTokenResponseReceived); + Assert.Equal(ExpectUserInfoReceived, InvokedUserInfoReceived); + Assert.Equal(ExpectAuthenticationFailed, InvokeAuthenticationFailed); + Assert.Equal(ExpectTicketReceived, InvokedTicketReceived); + Assert.Equal(ExpectAccessDenied, InvokedAccessDenied); + Assert.Equal(ExpectRemoteFailure, InvokedRemoteFailure); + Assert.Equal(ExpectRedirectForSignOut, InvokedRedirectForSignOut); + Assert.Equal(ExpectRemoteSignOut, InvokedRemoteSignOut); + Assert.Equal(ExpectRedirectToSignedOut, InvokedRedirectToSignedOut); + } + } + + private TestServer CreateServer(OpenIdConnectEvents events, RequestDelegate appCode) + { + var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .ConfigureServices(services => + { + services.AddAuthentication(auth => + { + auth.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + auth.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddOpenIdConnect(o => + { + o.Events = events; + o.ClientId = "ClientId"; + o.GetClaimsFromUserInfoEndpoint = true; + o.Configuration = new OpenIdConnectConfiguration() + { + TokenEndpoint = "http://testhost/tokens", + UserInfoEndpoint = "http://testhost/user", + EndSessionEndpoint = "http://testhost/end" + }; + o.StateDataFormat = new TestStateDataFormat(); + o.UseSecurityTokenValidator = false; + o.TokenHandler = new TestTokenHandler(); + o.ProtocolValidator = new TestProtocolValidator(); + o.BackchannelHttpHandler = new TestBackchannel(); + }); + }) + .Configure(app => + { + app.UseAuthentication(); + app.Run(appCode); + })) + .Build(); + + host.Start(); + return host.GetTestServer(); + } + + private Task PostAsync(TestServer server, string path, string form) + { + var client = server.CreateClient(); + var cookie = ".AspNetCore.Correlation.correlationId=N"; + client.DefaultRequestHeaders.Add("Cookie", cookie); + return client.PostAsync("signin-oidc", + new StringContent(form, Encoding.ASCII, "application/x-www-form-urlencoded")); + } + + private class TestStateDataFormat : ISecureDataFormat + { + private AuthenticationProperties Data { get; set; } + + public string Protect(AuthenticationProperties data) + { + return "protected_state"; + } + + public string Protect(AuthenticationProperties data, string purpose) + { + throw new NotImplementedException(); + } + + public AuthenticationProperties Unprotect(string protectedText) + { + Assert.Equal("protected_state", protectedText); + var properties = new AuthenticationProperties(new Dictionary() + { + { ".xsrf", "correlationId" }, + { OpenIdConnectDefaults.RedirectUriForCodePropertiesKey, "redirect_uri" }, + { "testkey", "testvalue" } + }); + properties.RedirectUri = "http://testhost/redirect"; + return properties; + } + + public AuthenticationProperties Unprotect(string protectedText, string purpose) + { + throw new NotImplementedException(); + } + } + + private class TestTokenHandler : TokenHandler + { + public override Task ValidateTokenAsync(string token, TokenValidationParameters validationParameters) + { + Assert.Equal("my_id_token", token); + var jwt = new JwtSecurityToken(); + return Task.FromResult(new TokenValidationResult() + { + SecurityToken = new JsonWebToken(jwt.EncodedHeader + "." + jwt.EncodedPayload + "."), + ClaimsIdentity = new ClaimsIdentity("customAuthType"), + IsValid = true + }); + } + + public override SecurityToken ReadToken(string token) + { + Assert.Equal("my_id_token", token); + return new JsonWebToken(token); + } + } + + private class TestProtocolValidator : OpenIdConnectProtocolValidator + { + public override void ValidateAuthenticationResponse(OpenIdConnectProtocolValidationContext validationContext) + { + } + + public override void ValidateTokenResponse(OpenIdConnectProtocolValidationContext validationContext) + { + } + + public override void ValidateUserInfoResponse(OpenIdConnectProtocolValidationContext validationContext) + { + } + } + + private class TestBackchannel : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (string.Equals("/tokens", request.RequestUri.AbsolutePath, StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage() + { + Content = + new StringContent("{ \"id_token\": \"my_id_token\", \"access_token\": \"my_access_token\" }", Encoding.ASCII, "application/json") + }); + } + if (string.Equals("/user", request.RequestUri.AbsolutePath, StringComparison.Ordinal)) + { + return Task.FromResult(new HttpResponseMessage() { Content = new StringContent("{ }", Encoding.ASCII, "application/json") }); + } + + throw new NotImplementedException(request.RequestUri.ToString()); + } + } +} diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs index d9c3f04d9420..49977e6c80e9 100644 --- a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs @@ -374,7 +374,9 @@ public void MapInboundClaimsDefaultsToTrue() { var options = new OpenIdConnectOptions(); Assert.True(options.MapInboundClaims); +#pragma warning disable CS0618 // Type or member is obsolete var jwtHandler = options.SecurityTokenValidator as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete Assert.NotNull(jwtHandler); Assert.True(jwtHandler.MapInboundClaims); } @@ -385,7 +387,9 @@ public void MapInboundClaimsCanBeSetToFalse() var options = new OpenIdConnectOptions(); options.MapInboundClaims = false; Assert.False(options.MapInboundClaims); +#pragma warning disable CS0618 // Type or member is obsolete var jwtHandler = options.SecurityTokenValidator as JwtSecurityTokenHandler; +#pragma warning restore CS0618 // Type or member is obsolete Assert.NotNull(jwtHandler); Assert.False(jwtHandler.MapInboundClaims); } diff --git a/src/Security/Authentication/test/WsFederation/TestTokenHandler.cs b/src/Security/Authentication/test/WsFederation/TestTokenHandler.cs new file mode 100644 index 000000000000..a3a8d3d089ac --- /dev/null +++ b/src/Security/Authentication/test/WsFederation/TestTokenHandler.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.AspNetCore.Authentication.WsFederation; + +internal class TestSecurityTokenHandler : TokenHandler +{ + public override SecurityToken ReadToken(string token) + { + return new TestSecurityToken(); + } + + public override Task ValidateTokenAsync(string token, TokenValidationParameters validationParameters) + { + if (!string.IsNullOrEmpty(token) && token.Contains("ThisIsAValidToken")) + { + return Task.FromResult(new TokenValidationResult + { + ClaimsIdentity = new ClaimsIdentity("Test"), + IsValid = true, + SecurityToken = new TestSecurityToken() + }); + } + + throw new SecurityTokenException("The security token did not contain ThisIsAValidToken"); + } +} diff --git a/src/Security/Authentication/test/WsFederation/WsFederationTest.cs b/src/Security/Authentication/test/WsFederation/WsFederationTest.cs index e11ea3c57c73..6ea9ebdb733e 100644 --- a/src/Security/Authentication/test/WsFederation/WsFederationTest.cs +++ b/src/Security/Authentication/test/WsFederation/WsFederationTest.cs @@ -288,11 +288,14 @@ private async Task CreateClient(bool allowUnsolicited = false) .AddCookie() .AddWsFederation(options => { + options.UseSecurityTokenHandlers = true; options.Wtrealm = "http://Automation1"; options.MetadataAddress = "https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/federationmetadata/2007-06/federationmetadata.xml"; options.BackchannelHttpHandler = new WaadMetadataDocumentHandler(); options.StateDataFormat = new CustomStateDataFormat(); +#pragma warning disable CS0618 // Type or member is obsolete options.SecurityTokenHandlers = new List() { new TestSecurityTokenValidator() }; +#pragma warning restore CS0618 // Type or member is obsolete options.UseTokenLifetime = false; options.AllowUnsolicitedLogins = allowUnsolicited; options.Events = new WsFederationEvents() diff --git a/src/Security/Authentication/test/WsFederation/WsFederationTest_Handler.cs b/src/Security/Authentication/test/WsFederation/WsFederationTest_Handler.cs new file mode 100644 index 000000000000..da9590c9691b --- /dev/null +++ b/src/Security/Authentication/test/WsFederation/WsFederationTest_Handler.cs @@ -0,0 +1,453 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Authentication.WsFederation; + +public class WsFederationTestHandlers +{ + [Fact] + public async Task VerifySchemeDefaults() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationManager()); + services.AddAuthentication().AddWsFederation(); + var sp = services.BuildServiceProvider(); + var schemeProvider = sp.GetRequiredService(); + var scheme = await schemeProvider.GetSchemeAsync(WsFederationDefaults.AuthenticationScheme); + Assert.NotNull(scheme); + Assert.Equal("WsFederationHandler", scheme.HandlerType.Name); + Assert.Equal(WsFederationDefaults.AuthenticationScheme, scheme.DisplayName); + } + + [Fact] + public async Task MissingConfigurationThrows() + { + using var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .Configure(ConfigureApp) + .ConfigureServices(services => + { + services.AddAuthentication(sharedOptions => + { + sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddWsFederation(); + })) + .Build(); + + await host.StartAsync(); + using var server = host.GetTestServer(); + var httpClient = server.CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var exception = await Assert.ThrowsAsync(() => httpClient.GetAsync("/")); + Assert.Equal("Provide MetadataAddress, Configuration, or ConfigurationManager to WsFederationOptions", exception.Message); + } + + [Fact] + public async Task ChallengeRedirects() + { + var httpClient = await CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var response = await httpClient.GetAsync("/"); + Assert.Equal("https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/wsfed", response.Headers.Location.GetLeftPart(System.UriPartial.Path)); + var queryItems = QueryHelpers.ParseQuery(response.Headers.Location.Query); + + Assert.Equal("http://Automation1", queryItems["wtrealm"]); + Assert.True(queryItems["wctx"].ToString().Equals(CustomStateDataFormat.ValidStateData), "wctx does not equal ValidStateData"); + Assert.Equal(httpClient.BaseAddress + "signin-wsfed", queryItems["wreply"]); + Assert.Equal("wsignin1.0", queryItems["wa"]); + } + + [Fact] + public async Task MapWillNotAffectRedirect() + { + var httpClient = await CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var response = await httpClient.GetAsync("/mapped-challenge"); + Assert.Equal("https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/wsfed", response.Headers.Location.GetLeftPart(System.UriPartial.Path)); + var queryItems = QueryHelpers.ParseQuery(response.Headers.Location.Query); + + Assert.Equal("http://Automation1", queryItems["wtrealm"]); + Assert.True(queryItems["wctx"].ToString().Equals(CustomStateDataFormat.ValidStateData), "wctx does not equal ValidStateData"); + Assert.Equal(httpClient.BaseAddress + "signin-wsfed", queryItems["wreply"]); + Assert.Equal("wsignin1.0", queryItems["wa"]); + } + + [Fact] + public async Task PreMappedWillAffectRedirect() + { + var httpClient = await CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var response = await httpClient.GetAsync("/premapped-challenge"); + Assert.Equal("https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/wsfed", response.Headers.Location.GetLeftPart(System.UriPartial.Path)); + var queryItems = QueryHelpers.ParseQuery(response.Headers.Location.Query); + + Assert.Equal("http://Automation1", queryItems["wtrealm"]); + Assert.True(queryItems["wctx"].ToString().Equals(CustomStateDataFormat.ValidStateData), "wctx does not equal ValidStateData"); + Assert.Equal(httpClient.BaseAddress + "premapped-challenge/signin-wsfed", queryItems["wreply"]); + Assert.Equal("wsignin1.0", queryItems["wa"]); + } + + [Fact] + public async Task ValidTokenIsAccepted() + { + var httpClient = await CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var response = await httpClient.GetAsync("/"); + var queryItems = QueryHelpers.ParseQuery(response.Headers.Location.Query); + + var request = new HttpRequestMessage(HttpMethod.Post, queryItems["wreply"]); + CopyCookies(response, request); + request.Content = CreateSignInContent("WsFederation/ValidToken.xml", queryItems["wctx"]); + response = await httpClient.SendAsync(request); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + + request = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location); + CopyCookies(response, request); + response = await httpClient.SendAsync(request); + + // Did the request end in the actual resource requested for + Assert.Equal(WsFederationDefaults.AuthenticationScheme, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ValidUnsolicitedTokenIsRefused() + { + var httpClient = await CreateClient(); + var form = CreateSignInContent("WsFederation/ValidToken.xml", suppressWctx: true); + var exception = await Assert.ThrowsAsync(() => httpClient.PostAsync(httpClient.BaseAddress + "signin-wsfed", form)); + Assert.Contains("Unsolicited logins are not allowed.", exception.InnerException.Message); + } + + [Fact] + public async Task ValidUnsolicitedTokenIsAcceptedWhenAllowed() + { + var httpClient = await CreateClient(allowUnsolicited: true); + + var form = CreateSignInContent("WsFederation/ValidToken.xml", suppressWctx: true); + var response = await httpClient.PostAsync(httpClient.BaseAddress + "signin-wsfed", form); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + + var request = new HttpRequestMessage(HttpMethod.Get, response.Headers.Location); + CopyCookies(response, request); + response = await httpClient.SendAsync(request); + + // Did the request end in the actual resource requested for + Assert.Equal(WsFederationDefaults.AuthenticationScheme, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task InvalidTokenIsRejected() + { + var httpClient = await CreateClient(); + + // Verify if the request is redirected to STS with right parameters + var response = await httpClient.GetAsync("/"); + var queryItems = QueryHelpers.ParseQuery(response.Headers.Location.Query); + + var request = new HttpRequestMessage(HttpMethod.Post, queryItems["wreply"]); + CopyCookies(response, request); + request.Content = CreateSignInContent("WsFederation/InvalidToken.xml", queryItems["wctx"]); + response = await httpClient.SendAsync(request); + + // Did the request end in the actual resource requested for + Assert.Equal("AuthenticationFailed", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task RemoteSignoutRequestTriggersSignout() + { + var httpClient = await CreateClient(); + + var response = await httpClient.GetAsync("/signin-wsfed?wa=wsignoutcleanup1.0"); + response.EnsureSuccessStatusCode(); + + var cookie = response.Headers.GetValues(HeaderNames.SetCookie).Single(); + Assert.Equal(".AspNetCore.Cookies=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly", cookie); + Assert.Equal("OnRemoteSignOut", response.Headers.GetValues("EventHeader").Single()); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task EventsResolvedFromDI() + { + using var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddAuthentication(sharedOptions => + { + sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddWsFederation(options => + { + options.Wtrealm = "http://Automation1"; + options.MetadataAddress = "https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/federationmetadata/2007-06/federationmetadata.xml"; + options.BackchannelHttpHandler = new WaadMetadataDocumentHandler(); + options.EventsType = typeof(MyWsFedEvents); + }); + }) + .Configure(app => + { + app.Run(context => context.ChallengeAsync()); + })) + .Build(); + + await host.StartAsync(); + using var server = host.GetTestServer(); + + var result = await server.CreateClient().GetAsync(""); + Assert.Contains("CustomKey=CustomValue", result.Headers.Location.Query); + } + + private class MyWsFedEvents : WsFederationEvents + { + public override Task RedirectToIdentityProvider(RedirectContext context) + { + context.ProtocolMessage.SetParameter("CustomKey", "CustomValue"); + return base.RedirectToIdentityProvider(context); + } + } + + private FormUrlEncodedContent CreateSignInContent(string tokenFile, string wctx = null, bool suppressWctx = false) + { + var kvps = new List>(); + kvps.Add(new KeyValuePair("wa", "wsignin1.0")); + kvps.Add(new KeyValuePair("wresult", File.ReadAllText(tokenFile))); + if (!string.IsNullOrEmpty(wctx)) + { + kvps.Add(new KeyValuePair("wctx", wctx)); + } + if (suppressWctx) + { + kvps.Add(new KeyValuePair("suppressWctx", "true")); + } + return new FormUrlEncodedContent(kvps); + } + + private void CopyCookies(HttpResponseMessage response, HttpRequestMessage request) + { + var cookies = SetCookieHeaderValue.ParseList(response.Headers.GetValues(HeaderNames.SetCookie).ToList()); + foreach (var cookie in cookies) + { + if (cookie.Value.HasValue) + { + request.Headers.Add(HeaderNames.Cookie, new CookieHeaderValue(cookie.Name, cookie.Value).ToString()); + } + } + } + + private async Task CreateClient(bool allowUnsolicited = false) + { + var host = new HostBuilder() + .ConfigureWebHost(builder => + builder.UseTestServer() + .Configure(ConfigureApp) + .ConfigureServices(services => + { + services.AddAuthentication(sharedOptions => + { + sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + sharedOptions.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme; + }) + .AddCookie() + .AddWsFederation(options => + { + options.Wtrealm = "http://Automation1"; + options.MetadataAddress = "https://login.windows.net/4afbc689-805b-48cf-a24c-d4aa3248a248/federationmetadata/2007-06/federationmetadata.xml"; + options.BackchannelHttpHandler = new WaadMetadataDocumentHandler(); + options.StateDataFormat = new CustomStateDataFormat(); + options.TokenHandlers.Clear(); + options.TokenHandlers.Add(new TestSecurityTokenHandler()); + options.UseTokenLifetime = false; + options.AllowUnsolicitedLogins = allowUnsolicited; + options.Events = new WsFederationEvents() + { + OnMessageReceived = context => + { + if (!context.ProtocolMessage.Parameters.TryGetValue("suppressWctx", out var suppress)) + { + Assert.True(context.ProtocolMessage.Wctx.Equals("customValue"), "wctx is not my custom value"); + } + context.HttpContext.Items["MessageReceived"] = true; + return Task.FromResult(0); + }, + OnRedirectToIdentityProvider = context => + { + if (context.ProtocolMessage.IsSignInMessage) + { + // Sign in message + context.ProtocolMessage.Wctx = "customValue"; + } + + return Task.FromResult(0); + }, + OnSecurityTokenReceived = context => + { + context.HttpContext.Items["SecurityTokenReceived"] = true; + return Task.FromResult(0); + }, + OnSecurityTokenValidated = context => + { + Assert.True((bool)context.HttpContext.Items["MessageReceived"], "MessageReceived notification not invoked"); + Assert.True((bool)context.HttpContext.Items["SecurityTokenReceived"], "SecurityTokenReceived notification not invoked"); + + if (context.Principal != null) + { + var identity = context.Principal.Identities.Single(); + identity.AddClaim(new Claim("ReturnEndpoint", "true")); + identity.AddClaim(new Claim("Authenticated", "true")); + identity.AddClaim(new Claim(identity.RoleClaimType, "Guest", ClaimValueTypes.String)); + } + + return Task.FromResult(0); + }, + OnAuthenticationFailed = context => + { + context.HttpContext.Items["AuthenticationFailed"] = true; + //Change the request url to something different and skip Wsfed. This new url will handle the request and let us know if this notification was invoked. + context.HttpContext.Request.Path = new PathString("/AuthenticationFailed"); + context.SkipHandler(); + return Task.FromResult(0); + }, + OnRemoteSignOut = context => + { + context.Response.Headers["EventHeader"] = "OnRemoteSignOut"; + return Task.FromResult(0); + } + }; + }); + })) + .Build(); + + await host.StartAsync(); + var server = host.GetTestServer(); + return server.CreateClient(); + } + + private void ConfigureApp(IApplicationBuilder app) + { + app.Map("/PreMapped-Challenge", mapped => + { + mapped.UseAuthentication(); + mapped.Run(async context => + { + await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme); + }); + }); + + app.UseAuthentication(); + + app.Map("/Logout", subApp => + { + subApp.Run(async context => + { + if (context.User.Identity.IsAuthenticated) + { + var authProperties = new AuthenticationProperties() { RedirectUri = context.Request.GetEncodedUrl() }; + await context.SignOutAsync(WsFederationDefaults.AuthenticationScheme, authProperties); + await context.Response.WriteAsync("Signing out..."); + } + else + { + await context.Response.WriteAsync("SignedOut"); + } + }); + }); + + app.Map("/AuthenticationFailed", subApp => + { + subApp.Run(async context => + { + await context.Response.WriteAsync("AuthenticationFailed"); + }); + }); + + app.Map("/signout-wsfed", subApp => + { + subApp.Run(async context => + { + await context.Response.WriteAsync("signout-wsfed"); + }); + }); + + app.Map("/mapped-challenge", subApp => + { + subApp.Run(async context => + { + await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme); + }); + }); + + app.Run(async context => + { + var result = context.AuthenticateAsync(); + if (context.User == null || !context.User.Identity.IsAuthenticated) + { + await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme); + await context.Response.WriteAsync("Unauthorized"); + } + else + { + var identity = context.User.Identities.Single(); + if (identity.NameClaimType == "Name_Failed" && identity.RoleClaimType == "Role_Failed") + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("SignIn_Failed"); + } + else if (!identity.HasClaim("Authenticated", "true") || !identity.HasClaim("ReturnEndpoint", "true") || !identity.HasClaim(identity.RoleClaimType, "Guest")) + { + await context.Response.WriteAsync("Provider not invoked"); + return; + } + else + { + await context.Response.WriteAsync(WsFederationDefaults.AuthenticationScheme); + } + } + }); + } + + private class WaadMetadataDocumentHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var metadata = File.ReadAllText(@"WsFederation/federationmetadata.xml"); + var newResponse = new HttpResponseMessage() { Content = new StringContent(metadata, Encoding.UTF8, "text/xml") }; + return Task.FromResult(newResponse); + } + } +} diff --git a/src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs b/src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs index efbe400730f4..caf5c3c765a0 100644 --- a/src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs +++ b/src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs @@ -5,6 +5,7 @@ using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Security.Claims; +using System.Security.Cryptography; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Negotiate; using Microsoft.AspNetCore.Authorization; @@ -23,7 +24,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.FunctionalTests; public class Startup { - private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); + private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(SHA256.HashData(Guid.NewGuid().ToByteArray())); private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); public void ConfigureServices(IServiceCollection services) diff --git a/src/SignalR/clients/ts/FunctionalTests/Startup.cs b/src/SignalR/clients/ts/FunctionalTests/Startup.cs index 074a4c01e720..45d3f8546a71 100644 --- a/src/SignalR/clients/ts/FunctionalTests/Startup.cs +++ b/src/SignalR/clients/ts/FunctionalTests/Startup.cs @@ -5,6 +5,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Reflection; using System.Security.Claims; +using System.Security.Cryptography; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; @@ -21,7 +22,7 @@ namespace FunctionalTests; public class Startup { - private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); + private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(SHA256.HashData(Guid.NewGuid().ToByteArray())); private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); private int _numRedirects; diff --git a/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs b/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs index 5ebaa3683bd4..8774cc2ae5f7 100644 --- a/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs +++ b/src/SignalR/common/Http.Connections/test/HttpConnectionDispatcherTests.cs @@ -10,6 +10,7 @@ using System.Net.Http; using System.Net.WebSockets; using System.Security.Claims; +using System.Security.Cryptography; using System.Security.Principal; using System.Text; using Microsoft.AspNetCore.Authentication; @@ -2989,7 +2990,7 @@ public async Task ConnectionClosedRequestedTriggeredOnAuthExpiration() [InlineData(HttpTransportType.WebSockets)] public async Task AuthenticationExpirationSetOnAuthenticatedConnectionWithJWT(HttpTransportType transportType) { - SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); + SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(SHA256.HashData(Guid.NewGuid().ToByteArray())); JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); using var host = CreateHost(services => @@ -3151,7 +3152,7 @@ public async Task AuthenticationExpirationSetOnAuthenticatedConnectionWithCookie [InlineData(HttpTransportType.WebSockets)] public async Task AuthenticationExpirationUsesCorrectScheme(HttpTransportType transportType) { - var SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); + var SecurityKey = new SymmetricSecurityKey(SHA256.HashData(Guid.NewGuid().ToByteArray())); var JwtTokenHandler = new JwtSecurityTokenHandler(); using var host = CreateHost(services => diff --git a/src/SignalR/samples/JwtSample/Startup.cs b/src/SignalR/samples/JwtSample/Startup.cs index 18b1ae21ea35..cefde00a4cd0 100644 --- a/src/SignalR/samples/JwtSample/Startup.cs +++ b/src/SignalR/samples/JwtSample/Startup.cs @@ -11,7 +11,7 @@ namespace JwtSample; public class Startup { - private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(16)); + private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)); private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); public void ConfigureServices(IServiceCollection services) diff --git a/src/SignalR/server/SignalR/test/Startup.cs b/src/SignalR/server/SignalR/test/Startup.cs index f3e0da4a9e71..eb2cd8ef19ee 100644 --- a/src/SignalR/server/SignalR/test/Startup.cs +++ b/src/SignalR/server/SignalR/test/Startup.cs @@ -5,6 +5,7 @@ using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Security.Claims; +using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -20,7 +21,7 @@ namespace Microsoft.AspNetCore.SignalR.Tests; public class Startup { - private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); + private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(SHA256.HashData(Guid.NewGuid().ToByteArray())); private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); public void ConfigureServices(IServiceCollection services)