diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/AuthenticationSessionUserTokenStore.cs index dc92447..51df083 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,10 @@ 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 IAuthenticationSchemeProvider _schemeProvider; 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 +29,19 @@ public class AuthenticationSessionUserAccessTokenStore : IUserTokenStore /// ctor /// /// + /// + /// /// - /// public AuthenticationSessionUserAccessTokenStore( IHttpContextAccessor contextAccessor, - ILogger logger, - IOptions options) + IStoreTokensInAuthenticationProperties tokensInProps, + IAuthenticationSchemeProvider schemeProvider, + ILogger logger) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); _logger = logger; - _options = options.Value; + _tokensInProps = tokensInProps; + _schemeProvider = schemeProvider; } /// @@ -79,92 +73,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 +88,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 +99,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 +128,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/Duende.AccessTokenManagement.OpenIdConnect.csproj b/src/Duende.AccessTokenManagement.OpenIdConnect/Duende.AccessTokenManagement.OpenIdConnect.csproj index a9caec1..b2f063a 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/Duende.AccessTokenManagement.OpenIdConnect.csproj +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/Duende.AccessTokenManagement.OpenIdConnect.csproj @@ -1,7 +1,7 @@ - net6.0;net8.0 + net8.0 enable true 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 a2adc9b..ae21466 100644 --- a/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/OpenIdConnectTokenManagementServiceCollectionExtensions.cs @@ -40,6 +40,8 @@ public static IServiceCollection AddOpenIdConnectAccessTokenManagement(this ISer services.TryAddSingleton(); services.TryAddTransient(); + services.TryAddSingleton(); + services.ConfigureOptions(); return services; diff --git a/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs b/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs new file mode 100644 index 0000000..b4030b2 --- /dev/null +++ b/src/Duende.AccessTokenManagement.OpenIdConnect/StoreTokensInAuthenticationProperties.cs @@ -0,0 +1,216 @@ +// 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( + IOptions tokenManagementOptions, // TODO - Consider option lifetime + IAuthenticationSchemeProvider schemeProvider, + ILogger logger, + IOptionsMonitor cookieOptionsMonitor) : 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 tokenManagementOptions.Value.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.DPoPKey); + authenticationProperties.Items.Remove(names.Expires); + 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/src/Duende.AccessTokenManagement/Duende.AccessTokenManagement.csproj b/src/Duende.AccessTokenManagement/Duende.AccessTokenManagement.csproj index cc934c5..08b512b 100644 --- a/src/Duende.AccessTokenManagement/Duende.AccessTokenManagement.csproj +++ b/src/Duende.AccessTokenManagement/Duende.AccessTokenManagement.csproj @@ -1,7 +1,7 @@  - net6.0;net8.0 + net8.0 enable true diff --git a/test/Tests/Tests.csproj b/test/Tests/Tests.csproj index 5e61d3c..13d46d5 100644 --- a/test/Tests/Tests.csproj +++ b/test/Tests/Tests.csproj @@ -1,7 +1,7 @@ - net6.0;net8.0 + net8.0 enable enable