Skip to content

Commit

Permalink
Merge pull request #100 from DuendeSoftware/joe/tokens-in-auth-proper…
Browse files Browse the repository at this point in the history
…ties

Add service for storage of tokens in auth properties
  • Loading branch information
josephdecock authored May 2, 2024
2 parents c80fe2b + 7a70f52 commit 5d114aa
Show file tree
Hide file tree
Showing 9 changed files with 709 additions and 165 deletions.
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,9 @@ 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 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 +28,16 @@ public class AuthenticationSessionUserAccessTokenStore : IUserTokenStore
/// ctor
/// </summary>
/// <param name="contextAccessor"></param>
/// <param name="tokensInProps"></param>
/// <param name="logger"></param>
/// <param name="options"></param>
public AuthenticationSessionUserAccessTokenStore(
IHttpContextAccessor contextAccessor,
ILogger<AuthenticationSessionUserAccessTokenStore> logger,
IOptions<UserTokenManagementOptions> options)
IStoreTokensInAuthenticationProperties tokensInProps,
ILogger<AuthenticationSessionUserAccessTokenStore> logger)
{
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
_logger = logger;
_options = options.Value;
_tokensInProps = tokensInProps;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -79,92 +69,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 +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 })
Expand All @@ -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<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 +124,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
Expand Up @@ -13,9 +13,6 @@ namespace Duende.AccessTokenManagement.OpenIdConnect;
/// <summary>
/// Accesses the current user from blazor server.
/// </summary>
/// <remarks>
/// ctor
/// </remarks>
public class BlazorServerUserAccessor(
// We use the CircuitServicesAccessor to resolve the
// AuthenticationStateProvider, rather than injecting it. Injecting the
Expand Down
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

0 comments on commit 5d114aa

Please sign in to comment.