From 217ca90d83598ebeefd873f7dcc728467dc4aec0 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 4 Nov 2024 08:38:47 +0100 Subject: [PATCH 1/4] Initial IClientContext abstraction --- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Authentication/AuthenticationClient.cs | 15 +- .../Internal/Auth/IClientContext.cs | 138 ++++++++++++++++++ 3 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/Altinn.App.Core/Internal/Auth/IClientContext.cs diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 11bba1458..e6aa3814f 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -114,6 +114,8 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); + + services.AddClientContext(); } private static void AddApplicationIdentifier(IServiceCollection services) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs index 29b1de1ca..d6e3a0e22 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs @@ -3,8 +3,7 @@ using Altinn.App.Core.Constants; using Altinn.App.Core.Extensions; using Altinn.App.Core.Internal.Auth; -using AltinnCore.Authentication.Utils; -using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,36 +15,36 @@ namespace Altinn.App.Core.Infrastructure.Clients.Authentication; public class AuthenticationClient : IAuthenticationClient { private readonly ILogger _logger; - private readonly IHttpContextAccessor _httpContextAccessor; private readonly HttpClient _client; + private readonly IClientContext _clientContext; /// /// Initializes a new instance of the class /// /// The current platform settings. /// the logger - /// The http context accessor /// A HttpClient provided by the HttpClientFactory. + /// The service provider. public AuthenticationClient( IOptions platformSettings, ILogger logger, - IHttpContextAccessor httpContextAccessor, - HttpClient httpClient + HttpClient httpClient, + IServiceProvider serviceProvider ) { _logger = logger; - _httpContextAccessor = httpContextAccessor; httpClient.BaseAddress = new Uri(platformSettings.Value.ApiAuthenticationEndpoint); httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _client = httpClient; + _clientContext = serviceProvider.GetRequiredService(); } /// public async Task RefreshToken() { string endpointUrl = $"refresh"; - string token = JwtTokenUtil.GetTokenFromContext(_httpContextAccessor.HttpContext, General.RuntimeCookieName); + string token = _clientContext.Current.Token; // TODO: check if authenticated? HttpResponseMessage response = await _client.GetAsync(token, endpointUrl); if (response.StatusCode == System.Net.HttpStatusCode.OK) diff --git a/src/Altinn.App.Core/Internal/Auth/IClientContext.cs b/src/Altinn.App.Core/Internal/Auth/IClientContext.cs new file mode 100644 index 000000000..28d8b1672 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Auth/IClientContext.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using AltinnCore.Authentication.Constants; +using AltinnCore.Authentication.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Internal.Auth; + +internal static class ClientContextDI +{ + internal static void AddClientContext(this IServiceCollection services) + { + services.TryAddSingleton(); + } +} + +internal abstract record ClientContextData(string Token) +{ + internal sealed record Unauthenticated(string Token) : ClientContextData(Token); + + internal sealed record User(int UserId, int PartyId, string Token) : ClientContextData(Token); + + internal sealed record Org(string OrgName, string OrgNo, int PartyId, string Token) : ClientContextData(Token); + + internal sealed record SystemUser(IReadOnlyList SystemUserId, string SystemId, string Token) + : ClientContextData(Token); + + // internal sealed record App(string Token) : ClientContextData; + + internal static ClientContextData From(HttpContext httpContext, string cookieName) + { + string token = JwtTokenUtil.GetTokenFromContext(httpContext, cookieName); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Couldn't extract current client token from context"); + + var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false; + if (!isAuthenticated) + return new Unauthenticated(token); + + var partyIdClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals(AltinnCoreClaimTypes.PartyID, StringComparison.OrdinalIgnoreCase) + ); + + if (string.IsNullOrWhiteSpace(partyIdClaim?.Value)) + throw new InvalidOperationException("Missing party ID claim for token"); + if (!int.TryParse(partyIdClaim.Value, CultureInfo.InvariantCulture, out int partyId)) + throw new InvalidOperationException("Invalid party ID claim value for token"); + + var orgClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals(AltinnCoreClaimTypes.Org, StringComparison.OrdinalIgnoreCase) + ); + var orgNoClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals(AltinnCoreClaimTypes.OrgNumber, StringComparison.OrdinalIgnoreCase) + ); + + if (!string.IsNullOrWhiteSpace(orgClaim?.Value)) + { + if (string.IsNullOrWhiteSpace(orgNoClaim?.Value)) + throw new InvalidOperationException("Missing org number claim for org token"); + + return new Org(orgClaim.Value, orgNoClaim.Value, partyId, token); + } + + var authorizationDetailsClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals("authorization_details", StringComparison.OrdinalIgnoreCase) + ); + if (!string.IsNullOrWhiteSpace(authorizationDetailsClaim?.Value)) + { + var authorizationDetails = JsonSerializer.Deserialize( + authorizationDetailsClaim.Value + ); + if (authorizationDetails is null) + throw new InvalidOperationException("Invalid authorization details claim value for token"); + if (authorizationDetails.Type != "urn:altinn:systemuser") + throw new InvalidOperationException( + "Receieved authorization details claim for unsupported client/user type" + ); + + var systemUser = JsonSerializer.Deserialize( + authorizationDetailsClaim.Value + ); + if (systemUser is null) + throw new InvalidOperationException("Invalid system user authorization details claim value for token"); + if (systemUser.SystemUserId is null || systemUser.SystemUserId.Count == 0) + throw new InvalidOperationException("Missing system user ID claim for system user token"); + if (string.IsNullOrWhiteSpace(systemUser.SystemId)) + throw new InvalidOperationException("Missing system ID claim for system user token"); + + return new SystemUser(systemUser.SystemUserId, systemUser.SystemId, token); + } + + var userIdClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals(AltinnCoreClaimTypes.UserId, StringComparison.OrdinalIgnoreCase) + ); + if (string.IsNullOrWhiteSpace(userIdClaim?.Value)) + throw new InvalidOperationException("Missing user ID claim for user token"); + if (!int.TryParse(userIdClaim.Value, CultureInfo.InvariantCulture, out int userId)) + throw new InvalidOperationException("Invalid user ID claim value for user token"); + + return new User(userId, partyId, token); + } + + private sealed record AuthorizationDetailsClaim([property: JsonPropertyName("type")] string Type); + + private sealed record SystemUserAuthorizationDetailsClaim( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("systemuser_id")] IReadOnlyList SystemUserId, + [property: JsonPropertyName("system_id")] string SystemId + ); +} + +internal interface IClientContext +{ + ClientContextData Current { get; } +} + +internal sealed class ClientContext : IClientContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptionsMonitor _appSettings; + + public ClientContext(IHttpContextAccessor httpContextAccessor, IOptionsMonitor appSettings) + { + _httpContextAccessor = httpContextAccessor; + _appSettings = appSettings; + } + + public ClientContextData Current => + ClientContextData.From( + _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"), + _appSettings.CurrentValue.RuntimeCookieName + ); +} From 038bf48f8fa96558f4981973b27ec53d40167723 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Fri, 8 Nov 2024 10:16:27 +0100 Subject: [PATCH 2/4] Rename to 'AuthenticationContext', make IAuthenticationContext more robust --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Authentication/AuthenticationClient.cs | 6 +- ...ntContext.cs => IAuthenticationContext.cs} | 68 ++++++++++++++----- 3 files changed, 54 insertions(+), 22 deletions(-) rename src/Altinn.App.Core/Internal/Auth/{IClientContext.cs => IAuthenticationContext.cs} (67%) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index e6aa3814f..df04ff4ba 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -115,7 +115,7 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); - services.AddClientContext(); + services.AddAuthenticationContext(); } private static void AddApplicationIdentifier(IServiceCollection services) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs index d6e3a0e22..fac39f41f 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Authentication/AuthenticationClient.cs @@ -16,7 +16,7 @@ public class AuthenticationClient : IAuthenticationClient { private readonly ILogger _logger; private readonly HttpClient _client; - private readonly IClientContext _clientContext; + private readonly IAuthenticationContext _authenticationContext; /// /// Initializes a new instance of the class @@ -37,14 +37,14 @@ IServiceProvider serviceProvider httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _client = httpClient; - _clientContext = serviceProvider.GetRequiredService(); + _authenticationContext = serviceProvider.GetRequiredService(); } /// public async Task RefreshToken() { string endpointUrl = $"refresh"; - string token = _clientContext.Current.Token; // TODO: check if authenticated? + string token = _authenticationContext.Current.Token; // TODO: check if authenticated? HttpResponseMessage response = await _client.GetAsync(token, endpointUrl); if (response.StatusCode == System.Net.HttpStatusCode.OK) diff --git a/src/Altinn.App.Core/Internal/Auth/IClientContext.cs b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs similarity index 67% rename from src/Altinn.App.Core/Internal/Auth/IClientContext.cs rename to src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs index 28d8b1672..343f8bb64 100644 --- a/src/Altinn.App.Core/Internal/Auth/IClientContext.cs +++ b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs @@ -11,28 +11,32 @@ namespace Altinn.App.Core.Internal.Auth; -internal static class ClientContextDI +internal static class AuthenticationContextDI { - internal static void AddClientContext(this IServiceCollection services) + internal static void AddAuthenticationContext(this IServiceCollection services) { - services.TryAddSingleton(); + services.TryAddSingleton(); } } -internal abstract record ClientContextData(string Token) +internal abstract record AuthenticationInfo { - internal sealed record Unauthenticated(string Token) : ClientContextData(Token); + public string Token { get; } - internal sealed record User(int UserId, int PartyId, string Token) : ClientContextData(Token); + private AuthenticationInfo(string token) => Token = token; - internal sealed record Org(string OrgName, string OrgNo, int PartyId, string Token) : ClientContextData(Token); + internal sealed record Unauthenticated(string Token) : AuthenticationInfo(Token); + + internal sealed record User(int UserId, int PartyId, string Token) : AuthenticationInfo(Token); + + internal sealed record Org(string OrgName, string OrgNo, int PartyId, string Token) : AuthenticationInfo(Token); internal sealed record SystemUser(IReadOnlyList SystemUserId, string SystemId, string Token) - : ClientContextData(Token); + : AuthenticationInfo(Token); // internal sealed record App(string Token) : ClientContextData; - internal static ClientContextData From(HttpContext httpContext, string cookieName) + internal static AuthenticationInfo From(HttpContext httpContext, string cookieName) { string token = JwtTokenUtil.GetTokenFromContext(httpContext, cookieName); if (string.IsNullOrWhiteSpace(token)) @@ -114,25 +118,53 @@ private sealed record SystemUserAuthorizationDetailsClaim( ); } -internal interface IClientContext +internal interface IAuthenticationContext { - ClientContextData Current { get; } + AuthenticationInfo Current { get; } } -internal sealed class ClientContext : IClientContext +internal sealed class AuthenticationContext : IAuthenticationContext { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOptionsMonitor _appSettings; - public ClientContext(IHttpContextAccessor httpContextAccessor, IOptionsMonitor appSettings) + private readonly object _lck = new(); + + public AuthenticationContext(IHttpContextAccessor httpContextAccessor, IOptionsMonitor appSettings) { _httpContextAccessor = httpContextAccessor; _appSettings = appSettings; } - public ClientContextData Current => - ClientContextData.From( - _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"), - _appSettings.CurrentValue.RuntimeCookieName - ); + public AuthenticationInfo Current + { + get + { + // Currently we're coupling this to the HTTP context directly. + // In the future we might want to run work (e.g. service tasks) in the background, + // at which point we won't always have a HTTP context available. + // At that point we probably want to implement something like an `IExecutionContext`, `IExecutionContextAccessor` + // to decouple ourselves from the ASP.NET request context. + // TODO: consider removing dependcy on HTTP context + var httpContext = + _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); + + lock (_lck) + { + const string key = "Internal_AltinnAuthenticationInfo"; + if (httpContext.Items.TryGetValue(key, out var authInfoObj)) + { + if (authInfoObj is not AuthenticationInfo authInfo) + throw new InvalidOperationException("Invalid authentication info object in HTTP context items"); + return authInfo; + } + else + { + var authInfo = AuthenticationInfo.From(httpContext, _appSettings.CurrentValue.RuntimeCookieName); + httpContext.Items[key] = authInfo; + return authInfo; + } + } + } + } } From e298d5af46fd1306a616fe95250d6aa7cf70b964 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Tue, 19 Nov 2024 08:45:00 +0100 Subject: [PATCH 3/4] one more authenticated type --- .../Internal/Auth/IAuthenticationContext.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs index 343f8bb64..96efc812d 100644 --- a/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs +++ b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs @@ -29,7 +29,9 @@ internal sealed record Unauthenticated(string Token) : AuthenticationInfo(Token) internal sealed record User(int UserId, int PartyId, string Token) : AuthenticationInfo(Token); - internal sealed record Org(string OrgName, string OrgNo, int PartyId, string Token) : AuthenticationInfo(Token); + internal sealed record ServiceOwner(string OrgName, string OrgNo, string Token) : AuthenticationInfo(Token); + + internal sealed record Org(string OrgNo, int PartyId, string Token) : AuthenticationInfo(Token); internal sealed record SystemUser(IReadOnlyList SystemUserId, string SystemId, string Token) : AuthenticationInfo(Token); @@ -64,10 +66,20 @@ internal static AuthenticationInfo From(HttpContext httpContext, string cookieNa if (!string.IsNullOrWhiteSpace(orgClaim?.Value)) { + // In this case the token should have a serviceowner scope, + // due to the `urn:altinn:org` claim if (string.IsNullOrWhiteSpace(orgNoClaim?.Value)) throw new InvalidOperationException("Missing org number claim for org token"); + if (!string.IsNullOrWhiteSpace(partyIdClaim?.Value)) + throw new InvalidOperationException("Got service owner token"); + + // TODO: check if the org is the same as the owner of the app? A flag? - return new Org(orgClaim.Value, orgNoClaim.Value, partyId, token); + return new ServiceOwner(orgClaim.Value, orgNoClaim.Value, token); + } + else if (!string.IsNullOrWhiteSpace(orgNoClaim?.Value)) + { + return new Org(orgNoClaim.Value, partyId, token); } var authorizationDetailsClaim = httpContext.User.Claims.FirstOrDefault(claim => From 3cb8eab0c1b2d5db894bb95114f1260137a4dd29 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 20 Nov 2024 06:54:02 +0100 Subject: [PATCH 4/4] update --- .../Controllers/AuthenticationController.cs | 27 ++- .../Internal/Auth/IAuthenticationContext.cs | 156 +++++++++++++----- 2 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/AuthenticationController.cs b/src/Altinn.App.Api/Controllers/AuthenticationController.cs index 60c4504f8..0d955718e 100644 --- a/src/Altinn.App.Api/Controllers/AuthenticationController.cs +++ b/src/Altinn.App.Api/Controllers/AuthenticationController.cs @@ -1,7 +1,7 @@ -#nullable disable using Altinn.App.Core.Configuration; using Altinn.App.Core.Constants; using Altinn.App.Core.Internal.Auth; +using Altinn.Platform.Profile.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -15,16 +15,39 @@ public class AuthenticationController : ControllerBase { private readonly IAuthenticationClient _authenticationClient; private readonly GeneralSettings _settings; + private readonly IAuthenticationContext _authenticationContext; /// /// Initializes a new instance of the class /// - public AuthenticationController(IAuthenticationClient authenticationClient, IOptions settings) + public AuthenticationController( + IAuthenticationClient authenticationClient, + IOptions settings, + IAuthenticationContext authenticationContext + ) { _authenticationClient = authenticationClient; _settings = settings.Value; + _authenticationContext = authenticationContext; } + // /// + // /// Gets current party by reading cookie value and validating. + // /// + // /// Party id for selected party. If invalid, partyId for logged in user is returned. + // [Authorize] + // [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + // [HttpGet("{org}/{app}/api/[controller]/current")] + // public async Task GetCurrent() + // { + // bool returnPartyObject = false; + // } + + // private sealed record CurrentAuthenticationResponse + // { + // public required UserProfile? Profile { get; init; } + // } + /// /// Refreshes the AltinnStudioRuntime JwtToken when not in AltinnStudio mode. /// diff --git a/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs index 96efc812d..4403391ab 100644 --- a/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs +++ b/src/Altinn.App.Core/Internal/Auth/IAuthenticationContext.cs @@ -1,12 +1,18 @@ using System.Globalization; +using System.Net.Security; using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Internal.Profile; +using Altinn.App.Core.Internal.Registers; +using Altinn.Platform.Profile.Models; +using Altinn.Platform.Register.Models; using AltinnCore.Authentication.Constants; using AltinnCore.Authentication.Utils; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace Altinn.App.Core.Internal.Auth; @@ -19,28 +25,44 @@ internal static void AddAuthenticationContext(this IServiceCollection services) } } -internal abstract record AuthenticationInfo +public abstract record AuthenticationInfo { public string Token { get; } private AuthenticationInfo(string token) => Token = token; - internal sealed record Unauthenticated(string Token) : AuthenticationInfo(Token); + public sealed record Unauthenticated(string Token) : AuthenticationInfo(Token); - internal sealed record User(int UserId, int PartyId, string Token) : AuthenticationInfo(Token); + public sealed record User( + int UserId, + int PartyId, + Party? Reportee, + Party? Party, + UserProfile Profile, + int AuthenticationLevel, + string Token + ) : AuthenticationInfo(Token); - internal sealed record ServiceOwner(string OrgName, string OrgNo, string Token) : AuthenticationInfo(Token); + public sealed record ServiceOwner(string OrgName, string OrgNo, int AuthenticationLevel, string Token) + : AuthenticationInfo(Token); - internal sealed record Org(string OrgNo, int PartyId, string Token) : AuthenticationInfo(Token); + public sealed record Org(string OrgNo, int PartyId, int AuthenticationLevel, string Token) + : AuthenticationInfo(Token); - internal sealed record SystemUser(IReadOnlyList SystemUserId, string SystemId, string Token) + public sealed record SystemUser(IReadOnlyList SystemUserId, string SystemId, string Token) : AuthenticationInfo(Token); - // internal sealed record App(string Token) : ClientContextData; + // public sealed record App(string Token) : ClientContextData; - internal static AuthenticationInfo From(HttpContext httpContext, string cookieName) + internal static async Task From( + HttpContext httpContext, + string authCookieName, + string partyCookieName, + Func> getUserProfile, + Func> lookupParty + ) { - string token = JwtTokenUtil.GetTokenFromContext(httpContext, cookieName); + string token = JwtTokenUtil.GetTokenFromContext(httpContext, authCookieName); if (string.IsNullOrWhiteSpace(token)) throw new InvalidOperationException("Couldn't extract current client token from context"); @@ -64,22 +86,40 @@ internal static AuthenticationInfo From(HttpContext httpContext, string cookieNa claim.Type.Equals(AltinnCoreClaimTypes.OrgNumber, StringComparison.OrdinalIgnoreCase) ); + var authLevelClaim = httpContext.User.Claims.FirstOrDefault(claim => + claim.Type.Equals(AltinnCoreClaimTypes.AuthenticationLevel, StringComparison.OrdinalIgnoreCase) + ); + + int authLevel = -1; + static void ParseAuthLevel(string? value, out int authLevel) + { + if (!int.TryParse(value, CultureInfo.InvariantCulture, out authLevel)) + throw new InvalidOperationException("Missing authentication level claim value for token"); + + if (authLevel > 4 || authLevel < 0) // TODO - better validation? + throw new InvalidOperationException("Invalid authentication level claim value for token"); + } + if (!string.IsNullOrWhiteSpace(orgClaim?.Value)) { // In this case the token should have a serviceowner scope, // due to the `urn:altinn:org` claim if (string.IsNullOrWhiteSpace(orgNoClaim?.Value)) - throw new InvalidOperationException("Missing org number claim for org token"); + throw new InvalidOperationException("Missing org number claim for service owner token"); if (!string.IsNullOrWhiteSpace(partyIdClaim?.Value)) throw new InvalidOperationException("Got service owner token"); + ParseAuthLevel(authLevelClaim?.Value, out authLevel); + // TODO: check if the org is the same as the owner of the app? A flag? - return new ServiceOwner(orgClaim.Value, orgNoClaim.Value, token); + return new ServiceOwner(orgClaim.Value, orgNoClaim.Value, authLevel, token); } else if (!string.IsNullOrWhiteSpace(orgNoClaim?.Value)) { - return new Org(orgNoClaim.Value, partyId, token); + ParseAuthLevel(authLevelClaim?.Value, out authLevel); + + return new Org(orgNoClaim.Value, partyId, authLevel, token); } var authorizationDetailsClaim = httpContext.User.Claims.FirstOrDefault(claim => @@ -118,7 +158,22 @@ internal static AuthenticationInfo From(HttpContext httpContext, string cookieNa if (!int.TryParse(userIdClaim.Value, CultureInfo.InvariantCulture, out int userId)) throw new InvalidOperationException("Invalid user ID claim value for user token"); - return new User(userId, partyId, token); + var userProfile = + await getUserProfile(userId) + ?? throw new InvalidOperationException("Could not get user profile while getting user context"); + + if (httpContext.Request.Cookies.TryGetValue(partyCookieName, out var partyCookie) && partyCookie != null) + { + if (!int.TryParse(partyCookie, CultureInfo.InvariantCulture, out var cookiePartyId)) + throw new InvalidOperationException("Invalid party ID in cookie: " + partyCookie); + + partyId = cookiePartyId; + } + + ParseAuthLevel(authLevelClaim?.Value, out authLevel); + + var reportee = partyId == userProfile.PartyId ? userProfile.Party : await lookupParty(partyId); + return new User(userId, partyId, reportee, userProfile.Party, userProfile, authLevel, token); } private sealed record AuthorizationDetailsClaim([property: JsonPropertyName("type")] string Type); @@ -130,53 +185,68 @@ private sealed record SystemUserAuthorizationDetailsClaim( ); } -internal interface IAuthenticationContext +public interface IAuthenticationContext { AuthenticationInfo Current { get; } } internal sealed class AuthenticationContext : IAuthenticationContext { + private const string ItemsKey = "Internal_AltinnAuthenticationInfo"; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOptionsMonitor _appSettings; - - private readonly object _lck = new(); - - public AuthenticationContext(IHttpContextAccessor httpContextAccessor, IOptionsMonitor appSettings) + private readonly IOptionsMonitor _generalSettings; + private readonly IProfileClient _profileClient; + private readonly IAltinnPartyClient _altinnPartyClient; + + public AuthenticationContext( + IHttpContextAccessor httpContextAccessor, + IOptionsMonitor appSettings, + IOptionsMonitor generalSettings, + IProfileClient profileClient, + IAltinnPartyClient altinnPartyClient + ) { _httpContextAccessor = httpContextAccessor; _appSettings = appSettings; + _generalSettings = generalSettings; + _profileClient = profileClient; + _altinnPartyClient = altinnPartyClient; + } + + // Currently we're coupling this to the HTTP context directly. + // In the future we might want to run work (e.g. service tasks) in the background, + // at which point we won't always have a HTTP context available. + // At that point we probably want to implement something like an `IExecutionContext`, `IExecutionContextAccessor` + // to decouple ourselves from the ASP.NET request context. + // TODO: consider removing dependcy on HTTP context + private HttpContext _httpContext => + _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); + + internal async Task ResolveCurrent() + { + var httpContext = _httpContext; + var authInfo = await AuthenticationInfo.From( + httpContext, + _appSettings.CurrentValue.RuntimeCookieName, + _generalSettings.CurrentValue.GetAltinnPartyCookieName, + _profileClient.GetUserProfile, + _altinnPartyClient.GetParty + ); + httpContext.Items[ItemsKey] = authInfo; } public AuthenticationInfo Current { get { - // Currently we're coupling this to the HTTP context directly. - // In the future we might want to run work (e.g. service tasks) in the background, - // at which point we won't always have a HTTP context available. - // At that point we probably want to implement something like an `IExecutionContext`, `IExecutionContextAccessor` - // to decouple ourselves from the ASP.NET request context. - // TODO: consider removing dependcy on HTTP context - var httpContext = - _httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context available"); - - lock (_lck) - { - const string key = "Internal_AltinnAuthenticationInfo"; - if (httpContext.Items.TryGetValue(key, out var authInfoObj)) - { - if (authInfoObj is not AuthenticationInfo authInfo) - throw new InvalidOperationException("Invalid authentication info object in HTTP context items"); - return authInfo; - } - else - { - var authInfo = AuthenticationInfo.From(httpContext, _appSettings.CurrentValue.RuntimeCookieName); - httpContext.Items[key] = authInfo; - return authInfo; - } - } + var httpContext = _httpContext; + + if (httpContext.Items.TryGetValue(ItemsKey, out var authInfoObj)) + throw new InvalidOperationException("Authentication info was not populated"); + if (authInfoObj is not AuthenticationInfo authInfo) + throw new InvalidOperationException("Invalid authentication info object in HTTP context items"); + return authInfo; } } }