From 6a3fca86b5b49f3396209488afbf6a2c8383c8b4 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 22 May 2017 16:51:48 -0700 Subject: [PATCH] Add support for proof of key for code exchange --- .../IdentityServiceClaimTypes.cs | 2 + ...oofOfKeyForCodeExchangeChallengeMethods.cs | 11 + ...ProofOfKeyForCodeExchangeParameterNames.cs | 12 + .../ProtocolError.cs | 5 + .../Tokens/AuthorizationCode.cs | 2 + .../AuthorizationRequestFactory.cs | 38 +++ ...OfKeyForCodeExchangeTokenClaimsProvider.cs | 30 +++ .../ProtocolErrorProvider.cs | 22 ++ .../TokenRequestFactory.cs | 81 +++++- ...ntityServiceServiceCollectionExtensions.cs | 2 + .../AuthorizationRequestFactoryTest.cs | 159 ++++++++++++ ...yForCodeExchangeTokenClaimsProviderTest.cs | 92 +++++++ .../TokenRequestFactoryTest.cs | 242 ++++++++++++++++-- 13 files changed, 679 insertions(+), 19 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeChallengeMethods.cs create mode 100644 src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeParameterNames.cs create mode 100644 src/Microsoft.AspNetCore.Identity.Service.Core/Claims/ProofOfKeyForCodeExchangeTokenClaimsProvider.cs create mode 100644 test/Microsoft.AspNetCore.Identity.Service.Core.Test/Claims/ProofOfKeyForCodeExchangeTokenClaimsProviderTest.cs diff --git a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/IdentityServiceClaimTypes.cs b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/IdentityServiceClaimTypes.cs index e0b9fba9d0..2c5e6355a4 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/IdentityServiceClaimTypes.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/IdentityServiceClaimTypes.cs @@ -29,5 +29,7 @@ namespace Microsoft.AspNetCore.Identity.Service public const string GrantedToken = "g_token"; public const string TenantId = "tid"; public const string Resource = "rid"; + public const string CodeChallenge = "c_chall"; + public const string CodeChallengeMethod = "c_chall_m"; } } diff --git a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeChallengeMethods.cs b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeChallengeMethods.cs new file mode 100644 index 0000000000..525d8969b0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeChallengeMethods.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Identity.Service +{ + public static class ProofOfKeyForCodeExchangeChallengeMethods + { + public const string Plain = "plain"; + public const string SHA256 = "S256"; + } +} diff --git a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeParameterNames.cs b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeParameterNames.cs new file mode 100644 index 0000000000..6d4217b0a4 --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProofOfKeyForCodeExchangeParameterNames.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Identity.Service +{ + public static class ProofOfKeyForCodeExchangeParameterNames + { + public const string CodeChallengeMethod = "code_challenge_method"; + public const string CodeChallenge = "code_challenge"; + public const string CodeVerifier = "code_verifier"; + } +} diff --git a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProtocolError.cs b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProtocolError.cs index 378425833e..dd48b52b13 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProtocolError.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/ProtocolError.cs @@ -1,10 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Diagnostics; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Microsoft.AspNetCore.Identity.Service { + [DebuggerDisplay("{DebuggerDisplay(),nq}")] public class AuthorizationRequestError { public AuthorizationRequestError(OpenIdConnectMessage error, string redirectUri, string responseMode) @@ -19,5 +21,8 @@ namespace Microsoft.AspNetCore.Identity.Service public string RedirectUri { get; set; } public string ResponseMode { get; set; } + + private string DebuggerDisplay() => + $"{Message.Error} - {Message.ErrorDescription} - {RedirectUri} - {ResponseMode} - {Message.State}"; } } diff --git a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/Tokens/AuthorizationCode.cs b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/Tokens/AuthorizationCode.cs index d052d59691..7bc7ea62fc 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Abstractions/Tokens/AuthorizationCode.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Abstractions/Tokens/AuthorizationCode.cs @@ -29,6 +29,8 @@ namespace Microsoft.AspNetCore.Identity.Service public string ClientId => GetClaimValue(IdentityServiceClaimTypes.ClientId); public string Resource => GetClaimValue(IdentityServiceClaimTypes.Resource); public string RedirectUri => GetClaimValue(IdentityServiceClaimTypes.RedirectUri); + public string CodeChallenge => GetClaimValue(IdentityServiceClaimTypes.CodeChallenge); + public string CodeChallengeMethod => GetClaimValue(IdentityServiceClaimTypes.CodeChallengeMethod); public IEnumerable Scopes => GetClaimValuesOrEmpty(IdentityServiceClaimTypes.Scope); public IEnumerable GrantedTokens => GetClaimValuesOrEmpty(IdentityServiceClaimTypes.GrantedToken); public string Nonce => GetClaimValue(IdentityServiceClaimTypes.Nonce); diff --git a/src/Microsoft.AspNetCore.Identity.Service.Core/AuthorizationRequestFactory.cs b/src/Microsoft.AspNetCore.Identity.Service.Core/AuthorizationRequestFactory.cs index 39d28f5eef..d056e65f17 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Core/AuthorizationRequestFactory.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Core/AuthorizationRequestFactory.cs @@ -192,6 +192,43 @@ namespace Microsoft.AspNetCore.Identity.Service } } + var (codeChallenge, codeChallengeError) = RequestParametersHelper.ValidateOptionalParameterIsUnique(requestParameters, ProofOfKeyForCodeExchangeParameterNames.CodeChallenge, _errorProvider); + if (codeChallengeError != null) + { + codeChallengeError.State = state; + return AuthorizationRequest.Invalid(new AuthorizationRequestError(codeChallengeError, redirectUri, responseMode)); + } + + if (codeChallenge != null) + { + // The code challenge needs to be 43 characters long as its the result of Base64URLEncode(SHA256(code_verifier)). + // We do this check here because the code challenge might get saved in the serialized authorization code and we + // want to prevent it from getting unnecessarily big. + if (codeChallenge.Length != 43) + { + var invalidCodeChallenge = _errorProvider.InvalidCodeChallenge(); + invalidCodeChallenge.State = state; + return AuthorizationRequest.Invalid(new AuthorizationRequestError( + invalidCodeChallenge, + redirectUri, + responseMode)); + } + + var (codeChallengeMethod, codeChallengeMethodError) = RequestParametersHelper.ValidateParameterIsUnique(requestParameters, ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod, _errorProvider); + if (codeChallengeMethodError != null) + { + codeChallengeMethodError.State = state; + return AuthorizationRequest.Invalid(new AuthorizationRequestError(codeChallengeMethodError, redirectUri, responseMode)); + } + + if (!codeChallengeMethod.Equals(ProofOfKeyForCodeExchangeChallengeMethods.SHA256, StringComparison.Ordinal)) + { + var invalidChallengeMethod = _errorProvider.InvalidCodeChallengeMethod(codeChallengeMethod); + invalidChallengeMethod.State = state; + return AuthorizationRequest.Invalid(new AuthorizationRequestError(invalidChallengeMethod, redirectUri, responseMode)); + } + } + var result = new OpenIdConnectMessage(requestParameters); result.RequestType = OpenIdConnectRequestType.Authentication; @@ -394,5 +431,6 @@ namespace Microsoft.AspNetCore.Identity.Service return (clientId, resolvedUriResult.Uri, null); } + } } diff --git a/src/Microsoft.AspNetCore.Identity.Service.Core/Claims/ProofOfKeyForCodeExchangeTokenClaimsProvider.cs b/src/Microsoft.AspNetCore.Identity.Service.Core/Claims/ProofOfKeyForCodeExchangeTokenClaimsProvider.cs new file mode 100644 index 0000000000..0067986c1f --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity.Service.Core/Claims/ProofOfKeyForCodeExchangeTokenClaimsProvider.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity.Service.Claims; + +namespace Microsoft.AspNetCore.Identity.Service.Core.Claims +{ + public class ProofOfKeyForCodeExchangeTokenClaimsProvider : ITokenClaimsProvider + { + public int Order => 100; + + public Task OnGeneratingClaims(TokenGeneratingContext context) + { + if(context.IsContextForTokenTypes(TokenTypes.AuthorizationCode) && + context.RequestParameters.Parameters.ContainsKey(ProofOfKeyForCodeExchangeParameterNames.CodeChallenge)) + { + context.AddClaimToCurrentToken( + IdentityServiceClaimTypes.CodeChallenge, + context.RequestParameters.Parameters[ProofOfKeyForCodeExchangeParameterNames.CodeChallenge]); + + context.AddClaimToCurrentToken( + IdentityServiceClaimTypes.CodeChallengeMethod, + context.RequestParameters.Parameters[ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod]); + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.AspNetCore.Identity.Service.Core/ProtocolErrorProvider.cs b/src/Microsoft.AspNetCore.Identity.Service.Core/ProtocolErrorProvider.cs index a84a225e5a..3fe64a7fa3 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Core/ProtocolErrorProvider.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Core/ProtocolErrorProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Microsoft.AspNetCore.Identity.Service @@ -78,6 +79,13 @@ namespace Microsoft.AspNetCore.Identity.Service $"The token is not yet active or it has expired."); } + public virtual OpenIdConnectMessage InvalidCodeVerifier() + { + return CreateError( + IdentityServiceErrorCodes.InvalidRequest, + $"The code_verifier is missing or invalid."); + } + public virtual OpenIdConnectMessage MultipleResourcesNotSupported(string resourceName, string name) { return CreateError( @@ -143,6 +151,20 @@ namespace Microsoft.AspNetCore.Identity.Service $"The prompt value 'none' can't be used in conjunction with other prompt values '{promptValue}'"); } + public virtual OpenIdConnectMessage InvalidCodeChallengeMethod(string challengeMethod) + { + return CreateError( + IdentityServiceErrorCodes.InvalidRequest, + $"The code challenge method '{challengeMethod ?? "plain"}' is invalid. Only S256 is supported."); + } + + public virtual OpenIdConnectMessage InvalidCodeChallenge() + { + return CreateError( + IdentityServiceErrorCodes.InvalidRequest, + $"The provided code challenge must be 43 characters long."); + } + private OpenIdConnectMessage CreateError(string code, string description, string uri = null) => new OpenIdConnectMessage { diff --git a/src/Microsoft.AspNetCore.Identity.Service.Core/TokenRequestFactory.cs b/src/Microsoft.AspNetCore.Identity.Service.Core/TokenRequestFactory.cs index 8223512842..cc30d060ce 100644 --- a/src/Microsoft.AspNetCore.Identity.Service.Core/TokenRequestFactory.cs +++ b/src/Microsoft.AspNetCore.Identity.Service.Core/TokenRequestFactory.cs @@ -4,13 +4,17 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Identity.Service { public class TokenRequestFactory : ITokenRequestFactory { + private static bool[] ValidCodeVerifierCharacters = CreateCodeVerifierValidCharacters(); + private readonly IClientIdValidator _clientIdValidator; private readonly ITokenManager _tokenManager; private readonly IRedirectUriResolver _redirectUriValidator; @@ -185,16 +189,18 @@ namespace Microsoft.AspNetCore.Identity.Service string clientId, AuthorizationGrant consentGrant) { + if (!(consentGrant.Token is AuthorizationCode code)) + { + throw new InvalidOperationException("Granted token must be an authorization code."); + } + var (redirectUri, redirectUriError) = RequestParametersHelper.ValidateOptionalParameterIsUnique(requestParameters, OpenIdConnectParameterNames.RedirectUri, _errorProvider); if (redirectUriError != null) { return redirectUriError; } - var tokenRedirectUri = consentGrant - .Token.SingleOrDefault(c => - string.Equals(c.Type, IdentityServiceClaimTypes.RedirectUri, StringComparison.Ordinal))?.Value; - + var tokenRedirectUri = code.RedirectUri; if (redirectUri == null && tokenRedirectUri != null) { return _errorProvider.MissingRequiredParameter(OpenIdConnectParameterNames.RedirectUri); @@ -211,8 +217,48 @@ namespace Microsoft.AspNetCore.Identity.Service return _errorProvider.InvalidRedirectUri(redirectUri); } + if (code.CodeChallenge != null) + { + if (!ProofOfKeyForCodeExchangeChallengeMethods.SHA256.Equals(code.CodeChallengeMethod, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Unsupported code challenge method."); + } + + var (verifier, verifierError) = RequestParametersHelper.ValidateParameterIsUnique(requestParameters, ProofOfKeyForCodeExchangeParameterNames.CodeVerifier, _errorProvider); + if (verifierError != null) + { + return verifierError; + } + + // code-verifier = [a-zA-Z0-9\-._~]{43,128} + if (verifier.Length < 43 || verifier.Length > 128) + { + return _errorProvider.InvalidCodeVerifier(); + } + + for (var i = 0; i < verifier.Length; i++) + { + if (verifier[i] > 127 || !ValidCodeVerifierCharacters[verifier[i]]) + { + return _errorProvider.InvalidCodeVerifier(); + } + } + + if (!string.Equals(code.CodeChallenge, GetComputedChallenge(verifier), StringComparison.Ordinal)) + { + return _errorProvider.InvalidCodeVerifier(); + } + } + return null; } + private string GetComputedChallenge(string verifier) + { + using (var hash = CryptographyHelpers.CreateSHA256()) + { + return Base64UrlEncoder.Encode(hash.ComputeHash(Encoding.ASCII.GetBytes(verifier))); + } + } private string GetGrantTypeParameter(IDictionary parameters, string grantType) { @@ -226,5 +272,32 @@ namespace Microsoft.AspNetCore.Identity.Service return null; } } + + // "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~-._" + private static bool[] CreateCodeVerifierValidCharacters() + { + var result = new bool[128]; + for (var i = 0x41; i <= 0x5A; i++) + { + result[i] = true; + } + + for (var i = 0x61; i <= 0x7A; i++) + { + result[i] = true; + } + + for (var i = 0x30; i <= 0x39; i++) + { + result[i] = true; + } + + result['-'] = true; + result['.'] = true; + result['_'] = true; + result['~'] = true; + + return result; + } } } diff --git a/src/Microsoft.AspNetCore.Identity.Service/IdentityServiceServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Identity.Service/IdentityServiceServiceCollectionExtensions.cs index 4ec8332478..b7c4209795 100644 --- a/src/Microsoft.AspNetCore.Identity.Service/IdentityServiceServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Identity.Service/IdentityServiceServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Identity.Service; using Microsoft.AspNetCore.Identity.Service.Claims; using Microsoft.AspNetCore.Identity.Service.Configuration; using Microsoft.AspNetCore.Identity.Service.Core; +using Microsoft.AspNetCore.Identity.Service.Core.Claims; using Microsoft.AspNetCore.Identity.Service.Metadata; using Microsoft.AspNetCore.Identity.Service.Serialization; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -82,6 +83,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/Microsoft.AspNetCore.Identity.Service.Core.Test/AuthorizationRequestFactoryTest.cs b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/AuthorizationRequestFactoryTest.cs index f149355b78..5545fe3641 100644 --- a/test/Microsoft.AspNetCore.Identity.Service.Core.Test/AuthorizationRequestFactoryTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/AuthorizationRequestFactoryTest.cs @@ -718,6 +718,165 @@ namespace Microsoft.AspNetCore.Identity.Service Assert.True(result.IsValid); } + [Fact] + public async Task FailsToCreateAuthorizationRequest_CodeChallenge_HasMultipleValues() + { + // Arrange + var parameters = new Dictionary + { + [OpenIdConnectParameterNames.ClientId] = new[] { "a" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "http://www.example.com/callback" }, + [OpenIdConnectParameterNames.ResponseType] = new[] { "code" }, + [OpenIdConnectParameterNames.ResponseMode] = new[] { "form_post" }, + [OpenIdConnectParameterNames.Nonce] = new[] { "asdf" }, + [OpenIdConnectParameterNames.Scope] = new[] { " openid profile " }, + [OpenIdConnectParameterNames.State] = new[] { "state" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "challenge1", "challenge2" } + }; + + var expectedError = new AuthorizationRequestError(ProtocolErrorProvider.TooManyParameters(ProofOfKeyForCodeExchangeParameterNames.CodeChallenge), null, null); + expectedError.Message.State = "state"; + + var factory = CreateAuthorizationRequestFactory(); + + // Act + var result = await factory.CreateAuthorizationRequestAsync(parameters); + + // Assert + Assert.False(result.IsValid); + Assert.Equal(expectedError, result.Error, IdentityServiceErrorComparer.Instance); + Assert.Equal("http://www.example.com/callback", result.Error.RedirectUri); + Assert.Equal(OpenIdConnectResponseMode.FormPost, result.Error.ResponseMode); + } + + [Theory] + [InlineData("tooshort")] + [InlineData("toolong_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public async Task FailsToCreateAuthorizationRequest_CodeChallenge_DoesNotHave43Characters(string challenge) + { + // Arrange + var parameters = new Dictionary + { + [OpenIdConnectParameterNames.ClientId] = new[] { "a" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "http://www.example.com/callback" }, + [OpenIdConnectParameterNames.ResponseType] = new[] { "code" }, + [OpenIdConnectParameterNames.ResponseMode] = new[] { "form_post" }, + [OpenIdConnectParameterNames.Nonce] = new[] { "asdf" }, + [OpenIdConnectParameterNames.Scope] = new[] { " openid profile " }, + [OpenIdConnectParameterNames.State] = new[] { "state" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { challenge } + }; + + var expectedError = new AuthorizationRequestError(ProtocolErrorProvider.InvalidCodeChallenge(), "http://www.example.com/callback", "form_post"); + expectedError.Message.State = "state"; + + var factory = CreateAuthorizationRequestFactory(); + + // Act + var result = await factory.CreateAuthorizationRequestAsync(parameters); + + // Assert + Assert.False(result.IsValid); + Assert.Equal(expectedError, result.Error, IdentityServiceErrorComparer.Instance); + Assert.Equal("http://www.example.com/callback", result.Error.RedirectUri); + Assert.Equal(OpenIdConnectResponseMode.FormPost, result.Error.ResponseMode); + } + + [Fact] + public async Task FailsToCreateAuthorizationRequest_CodeChallengeMethod_IsMissing() + { + // Arrange + var parameters = new Dictionary + { + [OpenIdConnectParameterNames.ClientId] = new[] { "a" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "http://www.example.com/callback" }, + [OpenIdConnectParameterNames.ResponseType] = new[] { "code" }, + [OpenIdConnectParameterNames.ResponseMode] = new[] { "form_post" }, + [OpenIdConnectParameterNames.Nonce] = new[] { "asdf" }, + [OpenIdConnectParameterNames.Scope] = new[] { " openid profile " }, + [OpenIdConnectParameterNames.State] = new[] { "state" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "0123456789012345678901234567890123456789012" } + }; + + var expectedError = new AuthorizationRequestError(ProtocolErrorProvider.MissingRequiredParameter(ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod), "http://www.example.com/callback", "form_post"); + expectedError.Message.State = "state"; + + var factory = CreateAuthorizationRequestFactory(); + + // Act + var result = await factory.CreateAuthorizationRequestAsync(parameters); + + // Assert + Assert.False(result.IsValid); + Assert.Equal(expectedError, result.Error, IdentityServiceErrorComparer.Instance); + Assert.Equal("http://www.example.com/callback", result.Error.RedirectUri); + Assert.Equal(OpenIdConnectResponseMode.FormPost, result.Error.ResponseMode); + } + + [Fact] + public async Task FailsToCreateAuthorizationRequest_CodeChallengeMethod_HasMultipleValues() + { + // Arrange + var parameters = new Dictionary + { + [OpenIdConnectParameterNames.ClientId] = new[] { "a" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "http://www.example.com/callback" }, + [OpenIdConnectParameterNames.ResponseType] = new[] { "code" }, + [OpenIdConnectParameterNames.ResponseMode] = new[] { "form_post" }, + [OpenIdConnectParameterNames.Nonce] = new[] { "asdf" }, + [OpenIdConnectParameterNames.Scope] = new[] { " openid profile " }, + [OpenIdConnectParameterNames.State] = new[] { "state" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "0123456789012345678901234567890123456789012" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod] = new[] { "S256", "plain" } + }; + + var expectedError = new AuthorizationRequestError(ProtocolErrorProvider.TooManyParameters(ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod), "http://www.example.com/callback", "form_post"); + expectedError.Message.State = "state"; + + var factory = CreateAuthorizationRequestFactory(); + + // Act + var result = await factory.CreateAuthorizationRequestAsync(parameters); + + // Assert + Assert.False(result.IsValid); + Assert.Equal(expectedError, result.Error, IdentityServiceErrorComparer.Instance); + Assert.Equal("http://www.example.com/callback", result.Error.RedirectUri); + Assert.Equal(OpenIdConnectResponseMode.FormPost, result.Error.ResponseMode); + } + + [Fact] + public async Task FailsToCreateAuthorizationRequest_CodeChallengeMethod_IsNotSHA256() + { + // Arrange + var parameters = new Dictionary + { + [OpenIdConnectParameterNames.ClientId] = new[] { "a" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "http://www.example.com/callback" }, + [OpenIdConnectParameterNames.ResponseType] = new[] { "code" }, + [OpenIdConnectParameterNames.ResponseMode] = new[] { "form_post" }, + [OpenIdConnectParameterNames.Nonce] = new[] { "asdf" }, + [OpenIdConnectParameterNames.Scope] = new[] { " openid profile " }, + [OpenIdConnectParameterNames.State] = new[] { "state" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "0123456789012345678901234567890123456789012" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod] = new[] { "plain" } + }; + + var expectedError = new AuthorizationRequestError(ProtocolErrorProvider.InvalidCodeChallengeMethod("plain"), null, null); + expectedError.Message.State = "state"; + + var factory = CreateAuthorizationRequestFactory(); + + // Act + var result = await factory.CreateAuthorizationRequestAsync(parameters); + + // Assert + Assert.False(result.IsValid); + Assert.Equal(expectedError, result.Error, IdentityServiceErrorComparer.Instance); + Assert.Equal("http://www.example.com/callback", result.Error.RedirectUri); + Assert.Equal(OpenIdConnectResponseMode.FormPost, result.Error.ResponseMode); + } + [Fact] public async Task CreatesAnAuthorizationRequest_IfAllParameters_AreCorrect() { diff --git a/test/Microsoft.AspNetCore.Identity.Service.Core.Test/Claims/ProofOfKeyForCodeExchangeTokenClaimsProviderTest.cs b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/Claims/ProofOfKeyForCodeExchangeTokenClaimsProviderTest.cs new file mode 100644 index 0000000000..cce28829c1 --- /dev/null +++ b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/Claims/ProofOfKeyForCodeExchangeTokenClaimsProviderTest.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Xunit; + +namespace Microsoft.AspNetCore.Identity.Service.Core.Claims +{ + public class ProofOfKeyForCodeExchangeTokenClaimsProviderTest + { + [Fact] + public async Task OnGeneratingClaims_AddsCodeChallengeAndChallengeMethod_ToTheAuthorizationCode() + { + // Arrange + var context = new TokenGeneratingContext( + new ClaimsPrincipal(), + new ClaimsPrincipal(), + new OpenIdConnectMessage(new Dictionary + { + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "challenge" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod] = new[] { "S256" }, + }), + new RequestGrants()); + + context.InitializeForToken(TokenTypes.AuthorizationCode); + + var provider = new ProofOfKeyForCodeExchangeTokenClaimsProvider(); + + // Act + await provider.OnGeneratingClaims(context); + + // Assert + Assert.Contains(context.CurrentClaims, c => c.Type == IdentityServiceClaimTypes.CodeChallenge && c.Value == "challenge"); + Assert.Contains(context.CurrentClaims, c => c.Type == IdentityServiceClaimTypes.CodeChallengeMethod && c.Value == "S256"); + } + + [Theory] + [InlineData(TokenTypes.AccessToken)] + [InlineData(TokenTypes.IdToken)] + [InlineData(TokenTypes.RefreshToken)] + public async Task OnGeneratingClaims_DoesNothing_ForOtherTokenTypes(string tokenType) + { + // Arrange + var context = new TokenGeneratingContext( + new ClaimsPrincipal(), + new ClaimsPrincipal(), + new OpenIdConnectMessage(new Dictionary + { + [ProofOfKeyForCodeExchangeParameterNames.CodeChallenge] = new[] { "challenge" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod] = new[] { "S256" }, + }), + new RequestGrants()); + + context.InitializeForToken(tokenType); + + var provider = new ProofOfKeyForCodeExchangeTokenClaimsProvider(); + + // Act + await provider.OnGeneratingClaims(context); + + // Assert + Assert.Empty(context.CurrentClaims); + } + + [Fact] + public async Task OnGeneratingClaims_DoesNothing_IfChallengeNotPresent() + { + // Arrange + var context = new TokenGeneratingContext( + new ClaimsPrincipal(), + new ClaimsPrincipal(), + new OpenIdConnectMessage(new Dictionary + { + [ProofOfKeyForCodeExchangeParameterNames.CodeChallengeMethod] = new[] { "S256" }, + }), + new RequestGrants()); + + context.InitializeForToken(TokenTypes.AuthorizationCode); + + var provider = new ProofOfKeyForCodeExchangeTokenClaimsProvider(); + + // Act + await provider.OnGeneratingClaims(context); + + // Assert + Assert.Empty(context.CurrentClaims); + } + } +} diff --git a/test/Microsoft.AspNetCore.Identity.Service.Core.Test/TokenRequestFactoryTest.cs b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/TokenRequestFactoryTest.cs index 6345a99ee7..8f9deb1386 100644 --- a/test/Microsoft.AspNetCore.Identity.Service.Core.Test/TokenRequestFactoryTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Service.Core.Test/TokenRequestFactoryTest.cs @@ -144,7 +144,7 @@ namespace Microsoft.AspNetCore.Identity.Service Mock.Of(), Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.MissingRequiredParameter(OpenIdConnectParameterNames.ClientId); @@ -173,7 +173,7 @@ namespace Microsoft.AspNetCore.Identity.Service GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.InvalidGrant(); @@ -200,7 +200,7 @@ namespace Microsoft.AspNetCore.Identity.Service GetClientIdValidator(isClientIdValid: false), Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.InvalidClientId("clientId"); @@ -227,7 +227,7 @@ namespace Microsoft.AspNetCore.Identity.Service GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: false), Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.InvalidClientCredentials(); @@ -258,7 +258,7 @@ namespace Microsoft.AspNetCore.Identity.Service Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.TooManyParameters(OpenIdConnectParameterNames.Scope); @@ -289,7 +289,7 @@ namespace Microsoft.AspNetCore.Identity.Service Mock.Of(), GetScopeResolver(hasInvalidScopes: true), Enumerable.Empty(), - GetTestTokenManager(GetValidToken(),null,null,Enumerable.Empty(), new[] { "openid" }), + GetTestTokenManager(GetValidAuthorizationCode(), null, null, Enumerable.Empty(), new[] { "openid" }), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.InvalidScope("invalid"); @@ -320,7 +320,7 @@ namespace Microsoft.AspNetCore.Identity.Service Mock.Of(), GetScopeResolver(hasInvalidScopes: false), Enumerable.Empty(), - GetTestTokenManager(GetValidToken(), null, null, Enumerable.Empty(), new[] { "openid" }), + GetTestTokenManager(GetValidAuthorizationCode(), null, null, Enumerable.Empty(), new[] { "openid" }), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.UnauthorizedScope(); @@ -350,7 +350,7 @@ namespace Microsoft.AspNetCore.Identity.Service GetRedirectUriValidator(isRedirectUriValid: false), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.MissingRequiredParameter(OpenIdConnectParameterNames.RedirectUri); @@ -364,6 +364,214 @@ namespace Microsoft.AspNetCore.Identity.Service Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); } + [Fact] + public async Task CreateTokenRequestAsyncFails_IfCodeVerifierIsMissing() + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"challenge"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + var expectedError = ProtocolErrorProvider.MissingRequiredParameter(ProofOfKeyForCodeExchangeParameterNames.CodeVerifier); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.False(tokenRequest.IsValid); + Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); + } + + [Fact] + public async Task CreateTokenRequestAsyncFails_IfCodeVerifier_HasMultipleValues() + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeVerifier] = new[] { "value1", "value2" }, + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"challenge"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + var expectedError = ProtocolErrorProvider.TooManyParameters(ProofOfKeyForCodeExchangeParameterNames.CodeVerifier); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.False(tokenRequest.IsValid); + Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); + } + + [Fact] + public async Task CreateTokenRequestAsyncFails_IfCodeVerifierIsInvalid() + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeVerifier] = new[] { "@" } + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"challenge"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + var expectedError = ProtocolErrorProvider.InvalidCodeVerifier(); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.False(tokenRequest.IsValid); + Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); + } + + [Theory] + [InlineData("tooShort")] + [InlineData("tooLooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong")] + public async Task CreateTokenRequestAsyncFails_IfTooShortOrTooLong(string verifier) + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeVerifier] = new[] { verifier } + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"challenge"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + var expectedError = ProtocolErrorProvider.InvalidCodeVerifier(); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.False(tokenRequest.IsValid); + Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); + } + + [Fact] + public async Task CreateTokenRequestAsyncFails_IfCodeVerifierDoesNotMatchChallenge() + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeVerifier] = new[] { "0123456789012345678901234567890123456789012" } + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"challenge"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + var expectedError = ProtocolErrorProvider.InvalidCodeVerifier(); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.False(tokenRequest.IsValid); + Assert.Equal(expectedError, tokenRequest.Error, IdentityServiceErrorComparer.Instance); + } + + [Fact] + public async Task CreateTokenRequestSucceeds_IfCodeVerifier_MatchesChallenge() + { + // Arrange + var requestParameters = new Dictionary + { + [OpenIdConnectParameterNames.GrantType] = new[] { "authorization_code" }, + [OpenIdConnectParameterNames.Code] = new[] { "valid" }, + [OpenIdConnectParameterNames.ClientId] = new[] { "clientId" }, + [OpenIdConnectParameterNames.RedirectUri] = new[] { "https://www.example.com" }, + [ProofOfKeyForCodeExchangeParameterNames.CodeVerifier] = new[] { "0123456789012345678901234567890123456789012" } + }; + + var tokenRequestFactory = new TokenRequestFactory( + GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), + GetRedirectUriValidator(isRedirectUriValid: true), + Mock.Of(), + Enumerable.Empty(), + GetTestTokenManager(GetValidAuthorizationCode(new[] { + new Claim(IdentityServiceClaimTypes.CodeChallenge,"_RpfHqw8pAZIomzVUE7sjRmHSM543WVdC4o-Kc4_3C0"), + new Claim(IdentityServiceClaimTypes.CodeChallengeMethod, ProofOfKeyForCodeExchangeChallengeMethods.SHA256), + })), + new TimeStampManager(), new ProtocolErrorProvider()); + + // Act + var tokenRequest = await tokenRequestFactory.CreateTokenRequestAsync(requestParameters); + + // Assert + Assert.NotNull(tokenRequest); + Assert.True(tokenRequest.IsValid); + } + private IRedirectUriResolver GetRedirectUriValidator(bool isRedirectUriValid) { var mock = new Mock(); @@ -390,7 +598,7 @@ namespace Microsoft.AspNetCore.Identity.Service GetClientIdValidator(isClientIdValid: true, areClientCredentialsValid: true), Mock.Of(), Mock.Of(), Enumerable.Empty(), - GetTestTokenManager(GetValidToken()), + GetTestTokenManager(GetValidAuthorizationCode()), new TimeStampManager(), new ProtocolErrorProvider()); var expectedError = ProtocolErrorProvider.MissingRequiredParameter(OpenIdConnectParameterNames.RedirectUri); @@ -419,21 +627,25 @@ namespace Microsoft.AspNetCore.Identity.Service return clientIdValidator.Object; } - private Token GetValidToken() + private Token GetValidAuthorizationCode(IEnumerable additionalClaims = null) { var notBefore = EpochTime.GetIntDate(DateTime.UtcNow - TimeSpan.FromMinutes(20)).ToString(); var expires = EpochTime.GetIntDate(DateTime.UtcNow + TimeSpan.FromMinutes(10)).ToString(); var issuedAt = EpochTime.GetIntDate(DateTime.UtcNow).ToString(); - var authorizedParty = "clientId"; - return new TestToken(new Claim[] + + return new AuthorizationCode(new Claim[] { new Claim(IdentityServiceClaimTypes.TokenUniqueId, Guid.NewGuid().ToString()), - new Claim(IdentityServiceClaimTypes.RedirectUri, "https://www.example.com"), new Claim(IdentityServiceClaimTypes.NotBefore,notBefore), new Claim(IdentityServiceClaimTypes.Expires,expires), new Claim(IdentityServiceClaimTypes.IssuedAt,issuedAt), - new Claim(IdentityServiceClaimTypes.AuthorizedParty, authorizedParty) - }); + new Claim(IdentityServiceClaimTypes.UserId,"userId"), + new Claim(IdentityServiceClaimTypes.ClientId,"clientId"), + new Claim(IdentityServiceClaimTypes.RedirectUri, "https://www.example.com"), + new Claim(IdentityServiceClaimTypes.Scope, "openid"), + new Claim(IdentityServiceClaimTypes.GrantedToken, "id_token") + } + .Concat(additionalClaims ?? Enumerable.Empty())); } private ITokenManager GetTestTokenManager(