diff --git a/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs b/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs index 1ca378b..8bc9cfc 100644 --- a/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs +++ b/src/DfE.CoreLibs.Security/Authorization/ApiOboTokenService.cs @@ -2,73 +2,104 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Microsoft.Identity.Web; using System.Security.Claims; +using DfE.CoreLibs.Security.Configurations; namespace DfE.CoreLibs.Security.Authorization { + /// - /// Provides functionality for acquiring API On-Behalf-Of (OBO) tokens for authenticated users. + /// Provides functionality for acquiring API On-Behalf-Of (OBO) tokens for authenticated users with caching. /// - public class ApiOboTokenService : IApiOboTokenService + public class ApiOboTokenService( + ITokenAcquisition tokenAcquisition, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration, + IMemoryCache memoryCache, + IOptions tokenSettingsOptions) + : IApiOboTokenService { - private readonly ITokenAcquisition _tokenAcquisition; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IConfiguration _configuration; - - /// - /// Initializes a new instance of the class. - /// - /// The token acquisition service for acquiring tokens. - /// Accessor for the current HTTP context, used to retrieve the user's claims. - /// Configuration used to retrieve role-to-scope mappings. - public ApiOboTokenService( - ITokenAcquisition tokenAcquisition, - IHttpContextAccessor httpContextAccessor, - IConfiguration configuration) - { - _tokenAcquisition = tokenAcquisition; - _httpContextAccessor = httpContextAccessor; - _configuration = configuration; - } + private readonly TokenSettings _tokenSettings = tokenSettingsOptions.Value; /// public async Task GetApiOboTokenAsync(string? authenticationScheme = null) { - var userRoles = _httpContextAccessor.HttpContext?.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); + var user = httpContextAccessor.HttpContext?.User; + if (user == null) + { + throw new UnauthorizedAccessException("User is not authenticated."); + } + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException("User ID is missing."); + } + + // Retrieve user roles + var userRoles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); if (userRoles == null || !userRoles.Any()) { throw new UnauthorizedAccessException("User does not have any roles assigned."); } - var apiClientId = _configuration["Authorization:ApiSettings:ApiClientId"]; + // Retrieve API client ID from configuration + var apiClientId = configuration["Authorization:ApiSettings:ApiClientId"]; if (string.IsNullOrWhiteSpace(apiClientId)) { throw new InvalidOperationException("API client ID is missing from configuration."); } - var scopeMappings = _configuration.GetSection("Authorization:ScopeMappings").Get>>(); + // Retrieve scope mappings from configuration + var scopeMappings = configuration.GetSection("Authorization:ScopeMappings").Get>>(); if (scopeMappings == null) { throw new InvalidOperationException("ScopeMappings section is missing from configuration."); } - // Map roles to scopes based on configuration, or use default scope if no roles match - var apiScopes = userRoles.SelectMany(role => scopeMappings.ContainsKey(role) ? scopeMappings[role] : new List()) + // Map roles to scopes based on configuration + var apiScopes = userRoles + .SelectMany(role => scopeMappings.TryGetValue(role, out var mapping) ? mapping : new List()) .Distinct() - .Select(scope => $"api://{apiClientId}/{scope}") - .ToArray(); + .ToList(); if (!apiScopes.Any()) { - var defaultScope = _configuration["ApiSettings:DefaultScope"]; - apiScopes = new[] { $"api://{apiClientId}/{defaultScope}" }; + var defaultScope = configuration["Authorization:ApiSettings:DefaultScope"]; + apiScopes = [defaultScope!]; } - // Acquire the access token with the determined API scopes - var apiToken = await _tokenAcquisition.GetAccessTokenForUserAsync(apiScopes, user: _httpContextAccessor.HttpContext?.User, authenticationScheme: authenticationScheme); + // Sort scopes to ensure consistent cache key generation + apiScopes.Sort(StringComparer.OrdinalIgnoreCase); + var scopesString = string.Join(",", apiScopes); + + // Generate a unique cache key based on user ID and scopes + var cacheKey = $"ApiOboToken_{userId}_{scopesString}"; + + if (memoryCache.TryGetValue(cacheKey, out var cachedToken)) + { + return cachedToken!; + } + + // Acquire a new token + var formattedScopes = apiScopes.Select(scope => $"api://{apiClientId}/{scope}").ToArray(); + + var apiToken = await tokenAcquisition.GetAccessTokenForUserAsync( + formattedScopes, + user: user, + authenticationScheme: authenticationScheme); + + // Calculate absolute expiration time: Now + Expiration - Buffer + var absoluteExpiration = DateTimeOffset.UtcNow + .AddMinutes(_tokenSettings.TokenLifetimeMinutes) + .Subtract(TimeSpan.FromSeconds(_tokenSettings.BufferInSeconds)); + + // Cache the token with absolute expiration + memoryCache.Set(cacheKey, apiToken, absoluteExpiration); return apiToken; } } -} +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Security/Configurations/TokenSettings.cs b/src/DfE.CoreLibs.Security/Configurations/TokenSettings.cs index b097371..f6b2aab 100644 --- a/src/DfE.CoreLibs.Security/Configurations/TokenSettings.cs +++ b/src/DfE.CoreLibs.Security/Configurations/TokenSettings.cs @@ -6,5 +6,6 @@ public class TokenSettings public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; public int TokenLifetimeMinutes { get; set; } = 10; + public int BufferInSeconds { get; set; } = 60; } -} +} \ No newline at end of file