Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add service for storage of tokens in auth properties #100

Merged
merged 6 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -22,13 +16,10 @@ namespace Duende.AccessTokenManagement.OpenIdConnect
/// </summary>
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<AuthenticationSessionUserAccessTokenStore> _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
Expand All @@ -38,16 +29,19 @@ public class AuthenticationSessionUserAccessTokenStore : IUserTokenStore
/// ctor
/// </summary>
/// <param name="contextAccessor"></param>
/// <param name="tokensInProps"></param>
/// <param name="schemeProvider"></param>
/// <param name="logger"></param>
/// <param name="options"></param>
public AuthenticationSessionUserAccessTokenStore(
IHttpContextAccessor contextAccessor,
ILogger<AuthenticationSessionUserAccessTokenStore> logger,
IOptions<UserTokenManagementOptions> options)
IStoreTokensInAuthenticationProperties tokensInProps,
IAuthenticationSchemeProvider schemeProvider,
ILogger<AuthenticationSessionUserAccessTokenStore> logger)
{
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
_logger = logger;
_options = options.Value;
_tokensInProps = tokensInProps;
_schemeProvider = schemeProvider;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -79,92 +73,9 @@ public async Task<UserToken> 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<KeyValuePair<string, string?>> 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<KeyValuePair<string, string?>> 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}";


/// <inheritdoc/>
public async Task StoreTokenAsync(
ClaimsPrincipal user,
Expand All @@ -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 })
Expand All @@ -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<IOptionsMonitor<CookieAuthenticationOptions>>();
var schemeProvider = _contextAccessor.HttpContext.RequestServices.GetRequiredService<IAuthenticationSchemeProvider>();
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<string, string?>(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
Expand All @@ -264,15 +128,5 @@ protected virtual Task<ClaimsPrincipal> FilterPrincipalAsync(ClaimsPrincipal pri
{
return Task.FromResult(principal);
}

/// <summary>
/// Confirm application has opted in to UseChallengeSchemeScopedTokens and a ChallengeScheme is provided upon storage and retrieval of tokens.
/// </summary>
/// <param name="parameters"></param>
/// <returns></returns>
protected virtual bool AppendChallengeSchemeToTokenNames(UserTokenRequestParameters parameters)
{
return _options.UseChallengeSchemeScopedTokens && !string.IsNullOrEmpty(parameters.ChallengeScheme);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Interface that encapsulates the logic of storing UserTokens in AuthenticationProperties
/// </summary>
public interface IStoreTokensInAuthenticationProperties
{
/// <summary>
/// Gets a UserToken from the AuthenticationProperties
/// </summary>
UserToken GetUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null);

/// <summary>
/// Sets a UserToken in the AuthenticationProperties.
/// </summary>
void SetUserToken(UserToken token, AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null);

/// <summary>
/// Removes a UserToken from the AuthenticationProperties.
/// </summary>
/// <param name="authenticationProperties"></param>
/// <param name="parameters"></param>
void RemoveUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null);

/// <summary>
/// Gets the scheme name used when storing a UserToken in an
/// AuthenticationProperties.
/// </summary>
Task<string> GetSchemeAsync(UserTokenRequestParameters? parameters = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public static IServiceCollection AddOpenIdConnectAccessTokenManagement(this ISer
services.TryAddSingleton<IUserTokenRequestSynchronization, UserTokenRequestSynchronization>();
services.TryAddTransient<IUserTokenEndpointService, UserTokenEndpointService>();

services.TryAddSingleton<IStoreTokensInAuthenticationProperties, StoreTokensInAuthenticationProperties>();

services.ConfigureOptions<ConfigureOpenIdConnectOptions>();

// By default, we assume that we are in a traditional web application
Expand Down
Loading
Loading