diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs index dc92447..382b289 100755 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs @@ -3,17 +3,11 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; using System; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.AspNetCore.Authentication.Cookies; namespace Duende.AccessTokenManagement.OpenIdConnect { @@ -22,13 +16,9 @@ namespace Duende.AccessTokenManagement.OpenIdConnect /// public class AuthenticationSessionUserAccessTokenStore : IUserTokenStore { - private const string TokenPrefix = ".Token."; - private const string TokenNamesKey = ".TokenNames"; - private const string DPoPKeyName = "dpop_proof_key"; - private readonly IHttpContextAccessor _contextAccessor; + private readonly IStoreTokensInAuthenticationProperties _tokensInProps; private readonly ILogger _logger; - private readonly UserTokenManagementOptions _options; // per-request cache so that if SignInAsync is used, we won't re-read the old/cached AuthenticateResult from the handler // this requires this service to be added as scoped to the DI system @@ -38,16 +28,16 @@ public class AuthenticationSessionUserAccessTokenStore : IUserTokenStore /// ctor /// /// + /// /// - /// public AuthenticationSessionUserAccessTokenStore( IHttpContextAccessor contextAccessor, - ILogger logger, - IOptions options) + IStoreTokensInAuthenticationProperties tokensInProps, + ILogger logger) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); _logger = logger; - _options = options.Value; + _tokensInProps = tokensInProps; } /// @@ -79,92 +69,9 @@ public async Task GetTokenAsync( return new UserToken() { Error = "No properties on authentication result" }; } - var tokens = result.Properties.Items.Where(i => i.Key.StartsWith(TokenPrefix)).ToList(); - if (!tokens.Any()) - { - _logger.LogInformation("No tokens found in cookie properties. SaveTokens must be enabled for automatic token refresh."); - - return new UserToken() { Error = "No tokens in properties" }; - } - - var tokenName = NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.AccessToken, parameters); - var tokenTypeName = NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.TokenType, parameters); - var expiresName = NamePrefixAndResourceSuffix("expires_at", parameters); - - // Note that we are not including the the resource suffix because - // there is no per-resource refresh token or dpop key - var refreshTokenName = NamePrefix(OpenIdConnectParameterNames.RefreshToken); - var dpopKeyName = NamePrefix(DPoPKeyName); - - var appendChallengeScheme = AppendChallengeSchemeToTokenNames(parameters); - - var accessToken = GetTokenValue(tokens, tokenName, appendChallengeScheme, parameters); - var accessTokenType = GetTokenValue(tokens, tokenTypeName, appendChallengeScheme, parameters); - var dpopKey = GetTokenValue(tokens, dpopKeyName, appendChallengeScheme, parameters); - var expiresAt = GetTokenValue(tokens, expiresName, appendChallengeScheme, parameters); - var refreshToken = GetTokenValue(tokens, refreshTokenName, appendChallengeScheme, parameters); - - DateTimeOffset dtExpires = DateTimeOffset.MaxValue; - if (expiresAt != null) - { - dtExpires = DateTimeOffset.Parse(expiresAt, CultureInfo.InvariantCulture); - } - - return new UserToken - { - AccessToken = accessToken, - AccessTokenType = accessTokenType, - DPoPJsonWebKey = dpopKey, - RefreshToken = refreshToken, - Expiration = dtExpires - }; - } - - // If we are using the challenge scheme, we try to get the token 2 ways - // (with and without the suffix). This is necessary because ASP.NET - // itself does not set the suffix, so we might not have one at all. - private static string? GetTokenValue(List> tokens, string key, bool appendChallengeScheme, UserTokenRequestParameters parameters) - { - string? token = null; - - if(appendChallengeScheme) - { - var scheme = parameters.ChallengeScheme; - token = GetTokenValue(tokens, ChallengeSuffix(key, scheme!)); - } - - if (token.IsMissing()) - { - token = GetTokenValue(tokens, key); - } - return token; - } - - private static string? GetTokenValue(List> tokens, string key) - { - return tokens.SingleOrDefault(t => t.Key == key).Value; - } - - /// Adds the .Token. prefix to the token name and, if the resource - /// parameter was included, the suffix marking this token as - /// per-resource. - private static string NamePrefixAndResourceSuffix(string type, UserTokenRequestParameters parameters) - { - var result = NamePrefix(type); - if(!string.IsNullOrEmpty(parameters.Resource)) - { - result = ResourceSuffix(result, parameters.Resource); - } - return result; + return _tokensInProps.GetUserToken(result.Properties, parameters); } - private static string NamePrefix(string name) => $"{TokenPrefix}{name}"; - - private static string ResourceSuffix(string name, string resource) => $"{name}::{resource}"; - - private static string ChallengeSuffix(string name, string challengeScheme) => $"{name}||{challengeScheme}"; - - /// public async Task StoreTokenAsync( ClaimsPrincipal user, @@ -177,7 +84,7 @@ public async Task StoreTokenAsync( // we use String.Empty as the key for a null SignInScheme if (!_cache.TryGetValue(parameters.SignInScheme ?? String.Empty, out var result)) { - result = await _contextAccessor!.HttpContext!.AuthenticateAsync(parameters.SignInScheme)!.ConfigureAwait(false); + result = await _contextAccessor.HttpContext!.AuthenticateAsync(parameters.SignInScheme)!.ConfigureAwait(false); } if (result is not { Succeeded: true }) @@ -188,58 +95,11 @@ public async Task StoreTokenAsync( // in case you want to filter certain claims before re-issuing the authentication session var transformedPrincipal = await FilterPrincipalAsync(result.Principal!).ConfigureAwait(false); - var tokenName = NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.AccessToken, parameters); - var tokenTypeName = NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.TokenType, parameters); - var expiresName = NamePrefixAndResourceSuffix("expires_at", parameters); - - // Note that we are not including the resource suffix because there - // is no per-resource refresh token or dpop key - var refreshTokenName = NamePrefix(OpenIdConnectParameterNames.RefreshToken); - var dpopKeyName = NamePrefix(DPoPKeyName); - - if (AppendChallengeSchemeToTokenNames(parameters)) - { - string challengeScheme = parameters.ChallengeScheme!; - tokenName = ChallengeSuffix(tokenName, challengeScheme); - tokenTypeName = ChallengeSuffix(tokenTypeName, challengeScheme); - dpopKeyName = ChallengeSuffix(dpopKeyName, challengeScheme); - expiresName = ChallengeSuffix(expiresName, challengeScheme); - refreshTokenName = ChallengeSuffix(refreshTokenName, challengeScheme); - } + _tokensInProps.SetUserToken(token, result.Properties, parameters); - result.Properties!.Items[tokenName] = token.AccessToken; - result.Properties!.Items[tokenTypeName] = token.AccessTokenType; - if (token.DPoPJsonWebKey != null) - { - result.Properties!.Items[dpopKeyName] = token.DPoPJsonWebKey; - } - result.Properties!.Items[expiresName] = token.Expiration.ToString("o", CultureInfo.InvariantCulture); + var scheme = await _tokensInProps.GetSchemeAsync(parameters); - if (token.RefreshToken != null) - { - result.Properties.Items[refreshTokenName] = token.RefreshToken; - } - - var options = _contextAccessor!.HttpContext!.RequestServices.GetRequiredService>(); - var schemeProvider = _contextAccessor.HttpContext.RequestServices.GetRequiredService(); - var scheme = parameters.SignInScheme ?? (await schemeProvider.GetDefaultSignInSchemeAsync().ConfigureAwait(false))?.Name; - var cookieOptions = options.Get(scheme); - - if (result.Properties.AllowRefresh == true || - (result.Properties.AllowRefresh == null && cookieOptions.SlidingExpiration)) - { - // this will allow the cookie to be issued with a new issued (and thus a new expiration) - result.Properties.IssuedUtc = null; - result.Properties.ExpiresUtc = null; - } - - result.Properties.Items.Remove(TokenNamesKey); - var tokenNames = result.Properties.Items - .Where(item => item.Key.StartsWith(TokenPrefix)) - .Select(item => item.Key.Substring(TokenPrefix.Length)); - result.Properties.Items.Add(new KeyValuePair(TokenNamesKey, string.Join(";", tokenNames))); - - await _contextAccessor.HttpContext.SignInAsync(parameters.SignInScheme, transformedPrincipal, result.Properties).ConfigureAwait(false); + await _contextAccessor.HttpContext!.SignInAsync(scheme, transformedPrincipal, result.Properties).ConfigureAwait(false); // add to the cache so if GetTokenAsync is called again, we will use the updated property values // we use String.Empty as the key for a null SignInScheme @@ -264,15 +124,5 @@ protected virtual Task FilterPrincipalAsync(ClaimsPrincipal pri { return Task.FromResult(principal); } - - /// - /// Confirm application has opted in to UseChallengeSchemeScopedTokens and a ChallengeScheme is provided upon storage and retrieval of tokens. - /// - /// - /// - protected virtual bool AppendChallengeSchemeToTokenNames(UserTokenRequestParameters parameters) - { - return _options.UseChallengeSchemeScopedTokens && !string.IsNullOrEmpty(parameters.ChallengeScheme); - } } } \ No newline at end of file diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs index 9e5314b..c1cf7b0 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/BlazorServerPrincipalAccessor.cs @@ -13,9 +13,6 @@ namespace Duende.AccessTokenManagement.OpenIdConnect; /// /// Accesses the current user from blazor server. /// -/// -/// ctor -/// public class BlazorServerUserAccessor( // We use the CircuitServicesAccessor to resolve the // AuthenticationStateProvider, rather than injecting it. Injecting the diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/IStoreTokensInAuthenticationProperties.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/IStoreTokensInAuthenticationProperties.cs new file mode 100644 index 0000000..131dded --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/IStoreTokensInAuthenticationProperties.cs @@ -0,0 +1,36 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +/// +/// Interface that encapsulates the logic of storing UserTokens in AuthenticationProperties +/// +public interface IStoreTokensInAuthenticationProperties +{ + /// + /// Gets a UserToken from the AuthenticationProperties + /// + UserToken GetUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null); + + /// + /// Sets a UserToken in the AuthenticationProperties. + /// + void SetUserToken(UserToken token, AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null); + + /// + /// Removes a UserToken from the AuthenticationProperties. + /// + /// + /// + void RemoveUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null); + + /// + /// Gets the scheme name used when storing a UserToken in an + /// AuthenticationProperties. + /// + Task GetSchemeAsync(UserTokenRequestParameters? parameters = null); +} diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs index faf1013..b8226e0 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs @@ -37,6 +37,8 @@ public static IServiceCollection AddOpenIdConnectAccessTokenManagement(this ISer services.TryAddSingleton(); services.TryAddTransient(); + services.TryAddSingleton(); + services.ConfigureOptions(); // By default, we assume that we are in a traditional web application diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs new file mode 100644 index 0000000..a56e781 --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs @@ -0,0 +1,242 @@ +// Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Duende.AccessTokenManagement.OpenIdConnect; + +/// +public class StoreTokensInAuthenticationProperties( + IOptionsMonitor tokenManagementOptionsMonitor, + IOptionsMonitor cookieOptionsMonitor, + IAuthenticationSchemeProvider schemeProvider, + ILogger logger +) : IStoreTokensInAuthenticationProperties +{ + private const string TokenPrefix = ".Token."; + private const string TokenNamesKey = ".TokenNames"; + private const string DPoPKeyName = "dpop_proof_key"; + + /// Adds the .Token. prefix to the token name and, if the resource + /// parameter was included, the suffix marking this token as + /// per-resource. + private static string NamePrefixAndResourceSuffix(string type, UserTokenRequestParameters? parameters) + { + var result = NamePrefix(type); + if (!string.IsNullOrEmpty(parameters?.Resource)) + { + result = ResourceSuffix(result, parameters.Resource); + } + return result; + } + + private static string NamePrefix(string name) => $"{TokenPrefix}{name}"; + + private static string ResourceSuffix(string name, string resource) => $"{name}::{resource}"; + + private static string ChallengeSuffix(string name, string challengeScheme) => $"{name}||{challengeScheme}"; + + /// + public UserToken GetUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null) + { + var tokens = authenticationProperties.Items.Where(i => i.Key.StartsWith(TokenPrefix)).ToList(); + if (!tokens.Any()) + { + logger.LogInformation("No tokens found in cookie properties. SaveTokens must be enabled for automatic token refresh."); + + return new UserToken() { Error = "No tokens in properties" }; + } + + var names = GetTokenNamesWithoutScheme(parameters); + + var appendChallengeScheme = AppendChallengeSchemeToTokenNames(parameters); + + var accessToken = GetTokenValue(tokens, names.Token, appendChallengeScheme, parameters); + var accessTokenType = GetTokenValue(tokens, names.TokenType, appendChallengeScheme, parameters); + var dpopKey = GetTokenValue(tokens, names.DPoPKey, appendChallengeScheme, parameters); + var expiresAt = GetTokenValue(tokens, names.Expires, appendChallengeScheme, parameters); + var refreshToken = GetTokenValue(tokens, names.RefreshToken, appendChallengeScheme, parameters); + + DateTimeOffset dtExpires = DateTimeOffset.MaxValue; + if (expiresAt != null) + { + dtExpires = DateTimeOffset.Parse(expiresAt, CultureInfo.InvariantCulture); + } + + return new UserToken + { + AccessToken = accessToken, + AccessTokenType = accessTokenType, + DPoPJsonWebKey = dpopKey, + RefreshToken = refreshToken, + Expiration = dtExpires + }; + } + + /// + public async void SetUserToken( + UserToken token, + AuthenticationProperties authenticationProperties, + UserTokenRequestParameters? parameters = null) + { + var tokenNames = GetTokenNamesWithScheme(parameters); + + authenticationProperties.Items[tokenNames.Token] = token.AccessToken; + authenticationProperties.Items[tokenNames.TokenType] = token.AccessTokenType; + if (token.DPoPJsonWebKey != null) + { + authenticationProperties.Items[tokenNames.DPoPKey] = token.DPoPJsonWebKey; + } + authenticationProperties.Items[tokenNames.Expires] = token.Expiration.ToString("o", CultureInfo.InvariantCulture); + + if (token.RefreshToken != null) + { + authenticationProperties.Items[tokenNames.RefreshToken] = token.RefreshToken; + } + + var authenticationScheme = await GetSchemeAsync(parameters); + var cookieOptions = cookieOptionsMonitor.Get(authenticationScheme); + + if (authenticationProperties.AllowRefresh == true || + (authenticationProperties.AllowRefresh == null && cookieOptions.SlidingExpiration)) + { + // this will allow the cookie to be issued with a new issued (and thus a new expiration) + authenticationProperties.IssuedUtc = null; + authenticationProperties.ExpiresUtc = null; + } + + authenticationProperties.Items.Remove(TokenNamesKey); + var allTokenNames = authenticationProperties.Items + .Where(item => item.Key.StartsWith(TokenPrefix)) + .Select(item => item.Key.Substring(TokenPrefix.Length)); + authenticationProperties.Items.Add(new KeyValuePair(TokenNamesKey, string.Join(";", allTokenNames))); + } + + // If we are using the challenge scheme, we try to get the token 2 ways + // (with and without the suffix). This is necessary because ASP.NET + // itself does not set the suffix, so we might not have one at all. + private static string? GetTokenValue(List> tokens, string key, bool appendChallengeScheme, UserTokenRequestParameters? parameters) + { + string? token = null; + + if (appendChallengeScheme) + { + string scheme = parameters?.ChallengeScheme ?? throw new InvalidOperationException("Attempt to append challenge scheme to token names, but no challenge scheme specified in UserTokenRequestParameters"); + token = tokens.SingleOrDefault(t => t.Key == ChallengeSuffix(key, scheme)).Value; + } + + if (token.IsMissing()) + { + token = tokens.SingleOrDefault(t => t.Key == key).Value; + } + + return token; + } + + /// + /// Confirm application has opted in to UseChallengeSchemeScopedTokens and a + /// ChallengeScheme is provided upon storage and retrieval of tokens. + /// + /// + /// + protected virtual bool AppendChallengeSchemeToTokenNames(UserTokenRequestParameters? parameters) + { + return tokenManagementOptionsMonitor.CurrentValue.UseChallengeSchemeScopedTokens && !string.IsNullOrEmpty(parameters?.ChallengeScheme); + } + + /// + public async Task GetSchemeAsync(UserTokenRequestParameters? parameters = null) + { + return parameters?.SignInScheme ?? + (await schemeProvider.GetDefaultSignInSchemeAsync().ConfigureAwait(false))?.Name ?? + throw new InvalidOperationException("No sign in scheme configured"); + } + + /// + public void RemoveUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null) + { + var names = GetTokenNamesWithScheme(parameters); + authenticationProperties.Items.Remove(names.Token); + authenticationProperties.Items.Remove(names.TokenType); + authenticationProperties.Items.Remove(names.Expires); + + // The DPoP key and refresh token are shared with all resources, so we + // can only delete them if no other tokens with a different resource + // exist. The key and refresh token are shared for all resources within + // a challenge scheme if we are using a challenge scheme. + + var keys = authenticationProperties.Items.Keys.Where(k => + k.StartsWith(NamePrefix(OpenIdConnectParameterNames.AccessToken))); + + var usingChallengeSuffix = AppendChallengeSchemeToTokenNames(parameters); + if (usingChallengeSuffix) + { + var challengeScheme = parameters?.ChallengeScheme ?? throw new InvalidOperationException("Attempt to use challenge scheme in token names, but no challenge scheme specified in UserTokenRequestParameters"); + var challengeSuffix = $"||{challengeScheme}"; + keys = keys.Where(k => k.EndsWith(challengeSuffix)); + } + + // If we see a resource separator now, we know there are other resources + // using the refresh token and/or dpop key and so we shouldn't delete + // them + var otherResourcesExist = keys.Any(k => k.Contains("::")); + + if(!otherResourcesExist) + { + authenticationProperties.Items.Remove(names.DPoPKey); + authenticationProperties.Items.Remove(names.RefreshToken); + } + } + + private TokenNames GetTokenNamesWithoutScheme(UserTokenRequestParameters? parameters = null) + { + return new TokenNames + ( + Token: NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.AccessToken, parameters), + TokenType: NamePrefixAndResourceSuffix(OpenIdConnectParameterNames.TokenType, parameters), + Expires: NamePrefixAndResourceSuffix("expires_at", parameters), + + // Note that we are not including the resource suffix because there + // is no per-resource refresh token or dpop key + RefreshToken: NamePrefix(OpenIdConnectParameterNames.RefreshToken), + DPoPKey: NamePrefix(DPoPKeyName) + ); + } + + private TokenNames GetTokenNamesWithScheme(TokenNames names, UserTokenRequestParameters? parameters = null) + { + if (AppendChallengeSchemeToTokenNames(parameters)) + { + // parameters?.ChallengeScheme should not be null after the call to AppendChallengeSchemeToTokenNames + // We check for that in the default implementation of AppendChallengeSchemeToTokenNames, but if an override + // didn't, that's an exception + string challengeScheme = parameters?.ChallengeScheme ?? throw new InvalidOperationException("Attempt to append challenge scheme to token names, but no challenge scheme specified in UserTokenRequestParameters"); + names = names with + { + Token = ChallengeSuffix(names.Token, challengeScheme), + TokenType = ChallengeSuffix(names.TokenType, challengeScheme), + DPoPKey = ChallengeSuffix(names.DPoPKey, challengeScheme), + Expires = ChallengeSuffix(names.Expires, challengeScheme), + RefreshToken = ChallengeSuffix(names.RefreshToken, challengeScheme) + }; + } + return names; + } + + private TokenNames GetTokenNamesWithScheme(UserTokenRequestParameters? parameters = null) + { + var names = GetTokenNamesWithoutScheme(parameters); + return GetTokenNamesWithScheme(names, parameters); + } +} + +record TokenNames(string Token, string TokenType, string DPoPKey, string Expires, string RefreshToken); diff --git a/test/Tests/ClientTokenManagementApiTests.cs b/test/Tests/ClientTokenManagementApiTests.cs index ceaf268..ca4d8ed 100644 --- a/test/Tests/ClientTokenManagementApiTests.cs +++ b/test/Tests/ClientTokenManagementApiTests.cs @@ -3,12 +3,10 @@ using Duende.IdentityServer.Configuration; using IdentityModel; -using IdentityModel.Client; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using System.Runtime.InteropServices; using System.Text; using System.Text.Json; diff --git a/test/Tests/Framework/TestOptionsMonitor.cs b/test/Tests/Framework/TestOptionsMonitor.cs new file mode 100644 index 0000000..ceccbbc --- /dev/null +++ b/test/Tests/Framework/TestOptionsMonitor.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Options; + +namespace Duende.AccessTokenManagement.Tests; + +public class TestOptionsMonitor(TOptions? currentValue = null) : IOptionsMonitor + where TOptions : class, new() +{ + public TOptions CurrentValue { get; set; } = currentValue ?? new(); + + public TOptions Get(string? name) + { + return CurrentValue; + } + + public IDisposable? OnChange(Action listener) + { + throw new NotImplementedException(); + } +} diff --git a/test/Tests/Framework/TestSchemeProvider.cs b/test/Tests/Framework/TestSchemeProvider.cs new file mode 100644 index 0000000..f6dae98 --- /dev/null +++ b/test/Tests/Framework/TestSchemeProvider.cs @@ -0,0 +1,72 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Duende.AccessTokenManagement.Tests; + +public class TestSchemeProvider : IAuthenticationSchemeProvider +{ + public TestSchemeProvider(string signInSchemeName = "testScheme") + { + DefaultSignInScheme = new AuthenticationScheme(signInSchemeName, signInSchemeName, typeof(CookieAuthenticationHandler)); + } + + public AuthenticationScheme? DefaultSignInScheme { get; set; } + + public Task GetDefaultSignInSchemeAsync() + { + return Task.FromResult(DefaultSignInScheme); + } + + #region Not Implemented (No tests have needed these yet) + + public void AddScheme(AuthenticationScheme scheme) + { + throw new NotImplementedException(); + } + + public Task> GetAllSchemesAsync() + { + throw new NotImplementedException(); + } + + public Task GetDefaultAuthenticateSchemeAsync() + { + throw new NotImplementedException(); + } + + public Task GetDefaultChallengeSchemeAsync() + { + throw new NotImplementedException(); + } + + public Task GetDefaultForbidSchemeAsync() + { + throw new NotImplementedException(); + } + + + public Task GetDefaultSignOutSchemeAsync() + { + throw new NotImplementedException(); + } + + public Task> GetRequestHandlerSchemesAsync() + { + throw new NotImplementedException(); + } + + public Task GetSchemeAsync(string name) + { + throw new NotImplementedException(); + } + + public void RemoveScheme(string name) + { + throw new NotImplementedException(); + } + + #endregion +} diff --git a/test/Tests/StoreTokensInAuthenticationPropertiesTests.cs b/test/Tests/StoreTokensInAuthenticationPropertiesTests.cs new file mode 100644 index 0000000..fb84187 --- /dev/null +++ b/test/Tests/StoreTokensInAuthenticationPropertiesTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.AccessTokenManagement.OpenIdConnect; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Duende.AccessTokenManagement.Tests; + +public class StoreTokensInAuthenticationPropertiesTests +{ + [Fact] + public void Should_be_able_to_store_and_retrieve_tokens() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var userToken = GenerateRandomUserToken(); + + sut.SetUserToken(userToken, authenticationProperties); + var result = sut.GetUserToken(authenticationProperties); + + result.ShouldBeEquivalentTo(userToken); + } + + [Fact] + public void Should_be_able_to_store_and_retrieve_tokens_for_multiple_challenge_schemes() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(new UserTokenManagementOptions + { + UseChallengeSchemeScopedTokens = true + }), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var tokenForScheme1 = GenerateRandomUserToken(); + var tokenForScheme2 = GenerateRandomUserToken(); + + var scheme1RequestParameters = new UserTokenRequestParameters + { + ChallengeScheme = "scheme1" + }; + var scheme2RequestParameters = new UserTokenRequestParameters + { + ChallengeScheme = "scheme2" + }; + + sut.SetUserToken(tokenForScheme1, authenticationProperties, scheme1RequestParameters); + sut.SetUserToken(tokenForScheme2, authenticationProperties, scheme2RequestParameters); + + var resultScheme1 = sut.GetUserToken(authenticationProperties, scheme1RequestParameters); + var resultScheme2 = sut.GetUserToken(authenticationProperties, scheme2RequestParameters); + + resultScheme1.ShouldBeEquivalentTo(tokenForScheme1); + resultScheme2.ShouldBeEquivalentTo(tokenForScheme2); + } + + [Fact] + public void Should_be_able_to_store_and_retrieve_tokens_for_multiple_resources() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var tokenForResource1 = GenerateRandomUserToken(); + var tokenForResource2 = GenerateAnotherTokenForADifferentResource(tokenForResource1); + + var resource1RequestParameters = new UserTokenRequestParameters + { + Resource = "resource1", + }; + var resource2RequestParameters = new UserTokenRequestParameters + { + Resource = "resource2", + }; + + sut.SetUserToken(tokenForResource1, authenticationProperties, resource1RequestParameters); + sut.SetUserToken(tokenForResource2, authenticationProperties, resource2RequestParameters); + + var resultForResource1 = sut.GetUserToken(authenticationProperties, resource1RequestParameters); + var resultForResource2 = sut.GetUserToken(authenticationProperties, resource2RequestParameters); + + resultForResource1.ShouldBeEquivalentTo(tokenForResource1); + resultForResource2.ShouldBeEquivalentTo(tokenForResource2); + } + + [Fact] + public void Should_be_able_to_store_and_retrieve_tokens_for_multiple_schemes_and_resources_at_the_same_time() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(new UserTokenManagementOptions + { + UseChallengeSchemeScopedTokens = true + }), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var tokenForResource1Scheme1 = GenerateRandomUserToken(); + var tokenForResource1Scheme2 = GenerateRandomUserToken(); + var tokenForResource2Scheme1 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme1); + var tokenForResource2Scheme2 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme2); + + var resource1Scheme1 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme1" + }; + + var resource1Scheme2 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme2" + }; + + var resource2Scheme1 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme1" + }; + + var resource2Scheme2 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme2" + }; + + sut.SetUserToken(tokenForResource1Scheme1, authenticationProperties, resource1Scheme1); + sut.SetUserToken(tokenForResource1Scheme2, authenticationProperties, resource1Scheme2); + sut.SetUserToken(tokenForResource2Scheme1, authenticationProperties, resource2Scheme1); + sut.SetUserToken(tokenForResource2Scheme2, authenticationProperties, resource2Scheme2); + + var resultForResource1Scheme1 = sut.GetUserToken(authenticationProperties, resource1Scheme1); + var resultForResource1Scheme2 = sut.GetUserToken(authenticationProperties, resource1Scheme2); + var resultForResource2Scheme1 = sut.GetUserToken(authenticationProperties, resource2Scheme1); + var resultForResource2Scheme2 = sut.GetUserToken(authenticationProperties, resource2Scheme2); + + resultForResource1Scheme1.ShouldBeEquivalentTo(tokenForResource1Scheme1); + resultForResource1Scheme2.ShouldBeEquivalentTo(tokenForResource1Scheme2); + resultForResource2Scheme1.ShouldBeEquivalentTo(tokenForResource2Scheme1); + resultForResource2Scheme2.ShouldBeEquivalentTo(tokenForResource2Scheme2); + } + + [Fact] + public void Should_be_able_to_remove_tokens() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var userToken = GenerateRandomUserToken(); + + sut.SetUserToken(userToken, authenticationProperties); + sut.RemoveUserToken(authenticationProperties); + var result = sut.GetUserToken(authenticationProperties); + + result.AccessToken.ShouldBeNull(); + result.AccessTokenType.ShouldBeNull(); + result.DPoPJsonWebKey.ShouldBeNull(); + result.RefreshToken.ShouldBeNull(); + result.Expiration.ShouldBe(default); + } + + + [Fact] + public void Should_be_able_to_remove_tokens_for_multiple_schemes_and_resources_at_the_same_time() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(new UserTokenManagementOptions + { + UseChallengeSchemeScopedTokens = true + }), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var tokenForResource1Scheme1 = GenerateRandomUserToken(); + var tokenForResource1Scheme2 = GenerateRandomUserToken(); + var tokenForResource2Scheme1 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme1); + var tokenForResource2Scheme2 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme2); + + var resource1Scheme1 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme1" + }; + + var resource1Scheme2 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme2" + }; + + var resource2Scheme1 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme1" + }; + + var resource2Scheme2 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme2" + }; + + sut.SetUserToken(tokenForResource1Scheme1, authenticationProperties, resource1Scheme1); + sut.SetUserToken(tokenForResource1Scheme2, authenticationProperties, resource1Scheme2); + sut.SetUserToken(tokenForResource2Scheme1, authenticationProperties, resource2Scheme1); + sut.SetUserToken(tokenForResource2Scheme2, authenticationProperties, resource2Scheme2); + + sut.RemoveUserToken(authenticationProperties, resource1Scheme1); + sut.RemoveUserToken(authenticationProperties, resource2Scheme2); + + var resultForResource1Scheme1 = sut.GetUserToken(authenticationProperties, resource1Scheme1); + var resultForResource1Scheme2 = sut.GetUserToken(authenticationProperties, resource1Scheme2); + var resultForResource2Scheme1 = sut.GetUserToken(authenticationProperties, resource2Scheme1); + var resultForResource2Scheme2 = sut.GetUserToken(authenticationProperties, resource2Scheme2); + + resultForResource1Scheme1.AccessToken.ShouldBeNull(); + resultForResource1Scheme2.ShouldBeEquivalentTo(tokenForResource1Scheme2); + resultForResource2Scheme1.ShouldBeEquivalentTo(tokenForResource2Scheme1); + resultForResource2Scheme2.AccessToken.ShouldBeNull(); + } + + + [Fact] + public void Removing_all_tokens_in_a_challenge_scheme_should_remove_items_shared_in_that_scheme() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(new UserTokenManagementOptions + { + UseChallengeSchemeScopedTokens = true + }), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + var tokenForResource1Scheme1 = GenerateRandomUserToken(); + var tokenForResource1Scheme2 = GenerateRandomUserToken(); + var tokenForResource2Scheme1 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme1); + var tokenForResource2Scheme2 = GenerateAnotherTokenForADifferentResource(tokenForResource1Scheme2); + + var resource1Scheme1 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme1" + }; + + var resource1Scheme2 = new UserTokenRequestParameters + { + Resource = "resource1", + ChallengeScheme = "scheme2" + }; + + var resource2Scheme1 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme1" + }; + + var resource2Scheme2 = new UserTokenRequestParameters + { + Resource = "resource2", + ChallengeScheme = "scheme2" + }; + + sut.SetUserToken(tokenForResource1Scheme1, authenticationProperties, resource1Scheme1); + sut.SetUserToken(tokenForResource1Scheme2, authenticationProperties, resource1Scheme2); + sut.SetUserToken(tokenForResource2Scheme1, authenticationProperties, resource2Scheme1); + sut.SetUserToken(tokenForResource2Scheme2, authenticationProperties, resource2Scheme2); + + sut.RemoveUserToken(authenticationProperties, resource1Scheme1); + sut.RemoveUserToken(authenticationProperties, resource1Scheme2); + sut.RemoveUserToken(authenticationProperties, resource2Scheme1); + sut.RemoveUserToken(authenticationProperties, resource2Scheme2); + + var resultForResource1Scheme1 = sut.GetUserToken(authenticationProperties, resource1Scheme1); + resultForResource1Scheme1.RefreshToken.ShouldBeNull(); + resultForResource1Scheme1.DPoPJsonWebKey.ShouldBeNull(); + } + + private UserToken GenerateRandomUserToken() => new UserToken + { + AccessToken = Guid.NewGuid().ToString(), + AccessTokenType = Guid.NewGuid().ToString(), + RefreshToken = Guid.NewGuid().ToString(), + Expiration = new DateTimeOffset(new DateTime(Random.Shared.Next())), + DPoPJsonWebKey = Guid.NewGuid().ToString() + }; + + private UserToken GenerateAnotherTokenForADifferentResource(UserToken previousToken) => new UserToken + { + AccessToken = Guid.NewGuid().ToString(), + AccessTokenType = Guid.NewGuid().ToString(), + Expiration = new DateTimeOffset(new DateTime(Random.Shared.Next())), + + // These two values don't change when we switch resources + RefreshToken = previousToken.RefreshToken, + DPoPJsonWebKey = previousToken.DPoPJsonWebKey, + }; +}