diff --git a/eng/Versions.props b/eng/Versions.props index cef16f91..693acd6a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -36,7 +36,7 @@ 4.7.0 6.4.0 2019.1.3 - 6.5.0 + 6.6.0 1.7.0 4.1.1 2.9.0 diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs index dc2db292..5724b625 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreConstants.cs @@ -19,8 +19,11 @@ namespace OpenIddict.Server.AspNetCore public static class JsonWebTokenTypes { - public const string AuthorizationRequest = "oi_auth_req"; - public const string LogoutRequest = "oi_lgt_req"; + public static class Private + { + public const string AuthorizationRequest = "oi_authrq+jwt"; + public const string LogoutRequest = "oi_lgtrq+jwt"; + } } public static class Properties diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs index b1771dd3..5f62153e 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Authentication.cs @@ -127,16 +127,12 @@ namespace OpenIddict.Server.AspNetCore return; } - // Restore the authorization request parameters from the serialized payload. - var parameters = new TokenValidationParameters - { - IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key), - TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key), - ValidateLifetime = false, - ValidAudience = context.Issuer.AbsoluteUri, - ValidIssuer = context.Issuer.AbsoluteUri, - ValidTypes = new[] { JsonWebTokenTypes.AuthorizationRequest } - }; + var parameters = context.Options.TokenValidationParameters.Clone(); + parameters.IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key); + parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); + parameters.ValidAudience = context.Issuer?.AbsoluteUri; + parameters.ValidIssuer = context.Issuer?.AbsoluteUri; + parameters.ValidTypes = new[] { JsonWebTokenTypes.Private.AuthorizationRequest }; var result = context.Options.JsonWebTokenHandler.ValidateToken(token, parameters); if (!result.IsValid) @@ -158,6 +154,7 @@ namespace OpenIddict.Server.AspNetCore throw new InvalidOperationException("The authorization request payload is malformed."); } + // Restore the authorization request parameters from the serialized payload. foreach (var parameter in document.RootElement.EnumerateObject()) { // Avoid overriding the current request parameters. @@ -253,13 +250,13 @@ namespace OpenIddict.Server.AspNetCore { AdditionalHeaderClaims = new Dictionary(StringComparer.Ordinal) { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.AuthorizationRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.AuthorizationRequest }, - Audience = context.Issuer.AbsoluteUri, + Audience = context.Issuer?.AbsoluteUri, Claims = context.Request.GetParameters().ToDictionary( parameter => parameter.Key, parameter => parameter.Value.Value), - Issuer = context.Issuer.AbsoluteUri, + Issuer = context.Issuer?.AbsoluteUri, SigningCredentials = context.Options.SigningCredentials.First(), Subject = new ClaimsIdentity() }); @@ -268,7 +265,7 @@ namespace OpenIddict.Server.AspNetCore encryptingCredentials: context.Options.EncryptionCredentials.First(), additionalHeaderClaims: new Dictionary { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.AuthorizationRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.AuthorizationRequest }); // Note: the cache key is always prefixed with a specific marker diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs index ac47f4cf..c7ad5e58 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.Session.cs @@ -125,16 +125,12 @@ namespace OpenIddict.Server.AspNetCore return; } - // Restore the authorization request parameters from the serialized payload. - var parameters = new TokenValidationParameters - { - IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key), - TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key), - ValidateLifetime = false, - ValidAudience = context.Issuer.AbsoluteUri, - ValidIssuer = context.Issuer.AbsoluteUri, - ValidTypes = new[] { JsonWebTokenTypes.LogoutRequest } - }; + var parameters = context.Options.TokenValidationParameters.Clone(); + parameters.IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key); + parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); + parameters.ValidAudience = context.Issuer?.AbsoluteUri; + parameters.ValidIssuer = context.Issuer?.AbsoluteUri; + parameters.ValidTypes = new[] { JsonWebTokenTypes.Private.LogoutRequest }; var result = context.Options.JsonWebTokenHandler.ValidateToken(token, parameters); if (!result.IsValid) @@ -156,6 +152,7 @@ namespace OpenIddict.Server.AspNetCore throw new InvalidOperationException("The logout request payload is malformed."); } + // Restore the authorization request parameters from the serialized payload. foreach (var parameter in document.RootElement.EnumerateObject()) { // Avoid overriding the current request parameters. @@ -251,13 +248,13 @@ namespace OpenIddict.Server.AspNetCore { AdditionalHeaderClaims = new Dictionary(StringComparer.Ordinal) { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.LogoutRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.LogoutRequest }, - Audience = context.Issuer.AbsoluteUri, + Audience = context.Issuer?.AbsoluteUri, Claims = context.Request.GetParameters().ToDictionary( parameter => parameter.Key, parameter => parameter.Value.Value), - Issuer = context.Issuer.AbsoluteUri, + Issuer = context.Issuer?.AbsoluteUri, SigningCredentials = context.Options.SigningCredentials.First(), Subject = new ClaimsIdentity() }); @@ -266,7 +263,7 @@ namespace OpenIddict.Server.AspNetCore encryptingCredentials: context.Options.EncryptionCredentials.First(), additionalHeaderClaims: new Dictionary { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.AuthorizationRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.AuthorizationRequest }); // Note: the cache key is always prefixed with a specific marker diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs index f4ac8c25..b3a4956e 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs @@ -19,8 +19,11 @@ namespace OpenIddict.Server.Owin public static class JsonWebTokenTypes { - public const string AuthorizationRequest = "oi_auth_req"; - public const string LogoutRequest = "oi_lgt_req"; + public static class Private + { + public const string AuthorizationRequest = "oi_authrq+jwt"; + public const string LogoutRequest = "oi_lgtrq+jwt"; + } } public static class Properties diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs index bbd87364..1ee04b47 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Authentication.cs @@ -126,16 +126,12 @@ namespace OpenIddict.Server.Owin return; } - // Restore the authorization request parameters from the serialized payload. - var parameters = new TokenValidationParameters - { - IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key), - TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key), - ValidateLifetime = false, - ValidAudience = context.Issuer.AbsoluteUri, - ValidIssuer = context.Issuer.AbsoluteUri, - ValidTypes = new[] { JsonWebTokenTypes.AuthorizationRequest } - }; + var parameters = context.Options.TokenValidationParameters.Clone(); + parameters.IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key); + parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); + parameters.ValidAudience = context.Issuer?.AbsoluteUri; + parameters.ValidIssuer = context.Issuer?.AbsoluteUri; + parameters.ValidTypes = new[] { JsonWebTokenTypes.Private.AuthorizationRequest }; var result = context.Options.JsonWebTokenHandler.ValidateToken(token, parameters); if (!result.IsValid) @@ -157,6 +153,7 @@ namespace OpenIddict.Server.Owin throw new InvalidOperationException("The authorization request payload is malformed."); } + // Restore the authorization request parameters from the serialized payload. foreach (var parameter in document.RootElement.EnumerateObject()) { // Avoid overriding the current request parameters. @@ -247,13 +244,13 @@ namespace OpenIddict.Server.Owin { AdditionalHeaderClaims = new Dictionary(StringComparer.Ordinal) { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.AuthorizationRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.AuthorizationRequest }, - Audience = context.Issuer.AbsoluteUri, + Audience = context.Issuer?.AbsoluteUri, Claims = context.Request.GetParameters().ToDictionary( parameter => parameter.Key, parameter => parameter.Value.Value), - Issuer = context.Issuer.AbsoluteUri, + Issuer = context.Issuer?.AbsoluteUri, SigningCredentials = context.Options.SigningCredentials.First(), Subject = new ClaimsIdentity() }); @@ -262,7 +259,7 @@ namespace OpenIddict.Server.Owin encryptingCredentials: context.Options.EncryptionCredentials.First(), additionalHeaderClaims: new Dictionary { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.AuthorizationRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.AuthorizationRequest }); // Note: the cache key is always prefixed with a specific marker diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs index 7192b2cc..bbe59ba4 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.Session.cs @@ -124,16 +124,12 @@ namespace OpenIddict.Server.Owin return; } - // Restore the authorization request parameters from the serialized payload. - var parameters = new TokenValidationParameters - { - IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key), - TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key), - ValidateLifetime = false, - ValidAudience = context.Issuer.AbsoluteUri, - ValidIssuer = context.Issuer.AbsoluteUri, - ValidTypes = new[] { JsonWebTokenTypes.LogoutRequest } - }; + var parameters = context.Options.TokenValidationParameters.Clone(); + parameters.IssuerSigningKeys = context.Options.SigningCredentials.Select(credentials => credentials.Key); + parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); + parameters.ValidAudience = context.Issuer?.AbsoluteUri; + parameters.ValidIssuer = context.Issuer?.AbsoluteUri; + parameters.ValidTypes = new[] { JsonWebTokenTypes.Private.LogoutRequest }; var result = context.Options.JsonWebTokenHandler.ValidateToken(token, parameters); if (!result.IsValid) @@ -155,6 +151,7 @@ namespace OpenIddict.Server.Owin throw new InvalidOperationException("The logout request payload is malformed."); } + // Restore the authorization request parameters from the serialized payload foreach (var parameter in document.RootElement.EnumerateObject()) { // Avoid overriding the current request parameters. @@ -245,13 +242,13 @@ namespace OpenIddict.Server.Owin { AdditionalHeaderClaims = new Dictionary(StringComparer.Ordinal) { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.LogoutRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.LogoutRequest }, - Audience = context.Issuer.AbsoluteUri, + Audience = context.Issuer?.AbsoluteUri, Claims = context.Request.GetParameters().ToDictionary( parameter => parameter.Key, parameter => parameter.Value.Value), - Issuer = context.Issuer.AbsoluteUri, + Issuer = context.Issuer?.AbsoluteUri, SigningCredentials = context.Options.SigningCredentials.First(), Subject = new ClaimsIdentity() }); @@ -260,7 +257,7 @@ namespace OpenIddict.Server.Owin encryptingCredentials: context.Options.EncryptionCredentials.First(), additionalHeaderClaims: new Dictionary { - [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.LogoutRequest + [JwtHeaderParameterNames.Typ] = JsonWebTokenTypes.Private.LogoutRequest }); // Note: the cache key is always prefixed with a specific marker diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 4d17d3c5..7ee0f0a5 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -445,7 +445,7 @@ namespace OpenIddict.Server parameters.TokenDecryptionKeys = context.Options.EncryptionCredentials.Select(credentials => credentials.Key); // If a specific token type is expected, override the default valid types to reject - // security tokens whose "typ" header doesn't match the expected token type. + // security tokens whose actual token type doesn't match the expected token type. if (!string.IsNullOrEmpty(context.TokenType)) { parameters.ValidTypes = new[] @@ -484,8 +484,8 @@ namespace OpenIddict.Server // Attach the principal extracted from the token to the parent event context. context.Principal = new ClaimsPrincipal(result.ClaimsIdentity); - // Store the token type as a special private claim. - context.Principal.SetTokenType(token.Typ switch + // Store the token type (resolved from "typ" or "token_usage") as a special private claim. + context.Principal.SetTokenType(result.TokenType switch { JsonWebTokenTypes.AccessToken => TokenTypeHints.AccessToken, JsonWebTokenTypes.IdentityToken => TokenTypeHints.IdToken, @@ -715,7 +715,7 @@ namespace OpenIddict.Server if (!string.Equals(type, context.TokenType, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException(new StringBuilder() - .AppendFormat("The type of token associated with the deserialized principal ({0})", type) + .AppendFormat("The type of token associated with the deserialized principal ({0}) ", type) .AppendFormat("doesn't match the expected token type ({0}).", context.TokenType) .ToString()); } diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 480e2ec3..a36567d5 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; @@ -113,6 +114,38 @@ namespace OpenIddict.Server ClockSkew = TimeSpan.Zero, NameClaimType = OpenIddictConstants.Claims.Name, RoleClaimType = OpenIddictConstants.Claims.Role, + // In previous versions of OpenIddict (1.x and 2.x), all the JWT tokens (access and identity tokens) + // were issued with the generic "typ": "JWT" header. To prevent confused deputy and token substitution + // attacks, a special "token_usage" claim was added to the JWT payload to convey the actual token type. + // This validator overrides the default logic used by IdentityModel to resolve the type from this claim. + TypeValidator = (type, token, parameters) => + { + if (string.IsNullOrEmpty(type)) + { + throw new SecurityTokenInvalidTypeException("The 'typ' header of the JWT token cannot be null or empty."); + } + + // If the generic type of the token is "JWT", try to resolve the actual type from the "token_usage" claim. + if (string.Equals(type, JwtConstants.HeaderType, StringComparison.OrdinalIgnoreCase) && + ((JsonWebToken) token).TryGetPayloadValue(OpenIddictConstants.Claims.TokenUsage, out string usage)) + { + type = usage switch + { + TokenTypeHints.AccessToken => JsonWebTokenTypes.AccessToken, + TokenTypeHints.IdToken => JsonWebTokenTypes.IdentityToken, + + _ => throw new NotSupportedException("The token usage of the JWT token is not supported.") + }; + } + + if (parameters.ValidTypes != null && parameters.ValidTypes.Any() && + !parameters.ValidTypes.Contains(type, StringComparer.Ordinal)) + { + throw new SecurityTokenInvalidTypeException("The type of the JWT token doesn't match the expected type."); + } + + return type; + }, // Note: audience and lifetime are manually validated by OpenIddict itself. ValidateAudience = false, ValidateLifetime = false, diff --git a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs index 8d14f410..6c10ecb9 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationOptions.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationOptions.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -92,6 +93,38 @@ namespace OpenIddict.Validation ClockSkew = TimeSpan.Zero, NameClaimType = Claims.Name, RoleClaimType = Claims.Role, + // In previous versions of OpenIddict (1.x and 2.x), all the JWT tokens (access and identity tokens) + // were issued with the generic "typ": "JWT" header. To prevent confused deputy and token substitution + // attacks, a special "token_usage" claim was added to the JWT payload to convey the actual token type. + // This validator overrides the default logic used by IdentityModel to resolve the type from this claim. + TypeValidator = (type, token, parameters) => + { + if (string.IsNullOrEmpty(type)) + { + throw new SecurityTokenInvalidTypeException("The 'typ' header of the JWT token cannot be null or empty."); + } + + // If the generic type of the token is "JWT", try to resolve the actual type from the "token_usage" claim. + if (string.Equals(type, JwtConstants.HeaderType, StringComparison.OrdinalIgnoreCase) && + ((JsonWebToken) token).TryGetPayloadValue(Claims.TokenUsage, out string usage)) + { + type = usage switch + { + TokenTypeHints.AccessToken => JsonWebTokenTypes.AccessToken, + TokenTypeHints.IdToken => JsonWebTokenTypes.IdentityToken, + + _ => throw new NotSupportedException("The token usage of the JWT token is not supported.") + }; + } + + if (parameters.ValidTypes != null && parameters.ValidTypes.Any() && + !parameters.ValidTypes.Contains(type, StringComparer.Ordinal)) + { + throw new SecurityTokenInvalidTypeException("The type of the JWT token doesn't match the expected type."); + } + + return type; + }, // Note: audience and lifetime are manually validated by OpenIddict itself. ValidateAudience = false, ValidateLifetime = false, diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index b3260951..cc580065 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -459,7 +459,7 @@ namespace OpenIddict.Server.FunctionalTests // Assert Assert.Equal(new StringBuilder() - .AppendFormat("The type of token associated with the deserialized principal ({0})", TokenTypeHints.AuthorizationCode) + .AppendFormat("The type of token associated with the deserialized principal ({0}) ", TokenTypeHints.AuthorizationCode) .AppendFormat("doesn't match the expected token type ({0}).", TokenTypeHints.AccessToken) .ToString(), exception.Message); }