diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 2e9c7c0b..9466f23a 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -46,6 +46,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `ActivityTimeout` | `TimeSpan` | The root `ActivityTimeout`, then `00:05:00` | How long a request proxied to this service may sit idle before AuthProxy cancels it. See [Timeouts and streaming](#timeouts-and-streaming). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | +| `AccessToken` | `ServiceAccessTokenConfig` | `null` | Forwards the signed-in user's access token for this service's audience to its backend. See [Forwarding the user's access token](#forwarding-the-users-access-token). | ### ServiceEndpointConfig properties @@ -606,3 +607,105 @@ The verification endpoint's response can optionally include a `tenant` property, carries on the issued tokens and can resolve into the `x-cratis-tenant-id` header on proxied requests. See [Back-channel client credentials](authentication.md#back-channel-client-credentials) for the full token, tenant-resolution, and refresh-token flow. + +--- + +## Forwarding the user's access token + +By default a backend learns who the user is from the identity headers only. A backend that has to call another +API on the user's behalf, such as Microsoft Graph or a downstream domain API through the on-behalf-of flow, +needs a real access token issued for it. With `AccessToken`, AuthProxy works as a backend for frontend (BFF). +It obtains an access token for the backend's audience for the signed-in user and forwards it as +`Authorization: Bearer `: + +```json +{ + "Cratis": { + "AuthProxy": { + "Services": { + "reporting": { + "Backend": { "BaseUrl": "http://reporting-api:8080/" }, + "Frontend": { "BaseUrl": "http://reporting-web:3000/" }, + "AccessToken": { + "Scopes": [ "api://reporting/access_as_user" ] + } + } + }, + "Authentication": { + "OidcProviders": [ + { + "Name": "Microsoft", + "Authority": "https://login.microsoftonline.com//v2.0", + "ClientId": "", + "ClientSecret": "", + "Scopes": [ "offline_access" ] + } + ] + } + } + } +} +``` + +The backend then validates an ordinary JWT. Its audience is the backend's own app registration, so the +backend can exchange it for downstream tokens without signing anyone in itself. + +### ServiceAccessTokenConfig properties + +| Property | Type | Description | +|----------|------|-------------| +| `Scopes` | `string[]` | Scopes to request for the backend's audience, for example `api://reporting/access_as_user` (Microsoft Entra ID). | +| `Resource` | `string` | Optional resource indicator ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) for identity providers that select the audience with `resource`. | +| `Provider` | `string` | Optional OIDC provider name. When set, only users who signed in with that provider get a token. Everyone else is refused. | + +At least one of `Scopes` or `Resource` is required, the service needs a `Backend`, and at least one OIDC +provider must be configured. AuthProxy refuses to start otherwise. + +### How the token is obtained + +- After an OIDC sign-in passes validation, AuthProxy keeps the refresh token the provider issues + **server-side** when issuing the session cookie. The cookie carries only an unguessable reference to it, + inside its encrypted ticket. In this OIDC flow, the refresh token, access tokens and ID token never reach + the browser. Failed sign-ins and identity-link callbacks create no stored token session. +- For each request to the backend, AuthProxy uses that refresh token at the provider's token endpoint + (`grant_type=refresh_token`) to get a token for the service's scopes. The token is cached per session and + audience, and renewed shortly before it expires. AuthProxy authenticates to the token endpoint with the + provider's `ClientSecret` or [client credential](authentication.md#client-credentials-certificates-and-federated-credentials), + and stores a rotated refresh token when the provider issues one. Once a refresh starts, it finishes under + a ten-second operation timeout independently of browser cancellation, so navigation does not discard + a received rotation. +- Request `offline_access` (or your provider's equivalent) in the provider's `Scopes`. Without a refresh + token AuthProxy cannot get access tokens, and logs a warning at each such sign-in. +- Signing out removes the refresh token and every access token kept for the session, even when a refresh + is in flight. Signing in again replaces the previous token session. Rotation and new audiences do not + extend an absolute session's original retention deadline. When `Session.SlidingExpiration` is enabled, + authenticated cookie activity renews token retention even on frontend or non-forwarding routes. + +### What is forwarded, and when it is refused + +- Only requests that are authenticated by the AuthProxy session and routed to the service's `Backend` get a + token. The token replaces any `Authorization` header the browser sent. +- Requests to the `Frontend`, requests on [anonymous paths](#anonymous-paths), and machine callers that + authenticate with their own bearer token ([client credentials](#client-credentials) or JWT bearer) are + forwarded as before. +- Tokens stay bound to the backend and audience selected for the request across configuration reloads. + If a request captures a token policy and destinations from different configuration versions, AuthProxy + refuses it with `503` before obtaining or forwarding a token. Retry after the reload completes. +- When no token can be obtained, the request is refused with `401` instead of being forwarded without one. + This happens when the session has no refresh token, the provider rejects the refresh token, the provider + cannot be reached, or the user signed in with another provider than `Provider`. An `invalid_grant` error + refuses that audience without discarding the session or other audiences: it can mean missing consent + or a resource-specific policy rather than an expired refresh token. Rejections are cached for 30 seconds + per session and audience to avoid repeatedly redeeming the same refused refresh token. Signing in again + clears the previous session's rejections. The frontend should treat the `401` + as a signal to sign in again through `/.cratis/login/{scheme}`; a missing consent or policy requirement + may also need to be addressed at the provider. + +### Running more than one instance + +Refresh and access tokens are kept in AuthProxy's memory, encrypted with its +[Data Protection keys](authentication.md#data-protection-keys-and-horizontal-scaling). They do not survive a +restart and are not shared between replicas. With several replicas, route each session to the same replica +(sticky sessions). Otherwise a request that lands on another replica is refused with `401` until the user +signs in again. Sessions that began before `AccessToken` was configured hold no refresh token either, so +their users sign in again once. diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs new file mode 100644 index 00000000..f3d77fb0 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_backend_receives_the_users_access_token.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// OWASP A01/A07. A backend that accepts the user's access token from AuthProxy must receive the token AuthProxy +/// obtained, never a value the browser sent, and must receive nothing at all when no token could be obtained. The +/// frontend never needs a token and never gets one. +/// +/// The running proxy and its origins. +[Collection(AccessTokenForwardingSpecCollection.Name)] +public class when_a_backend_receives_the_users_access_token(AccessTokenForwardingHarness harness) : IAsyncLifetime +{ + ForwardedRequest? _backendRequest; + ForwardedRequest? _frontendRequest; + HttpResponseMessage? _rejected; + bool _backendSawTheRejectedSession; + + public async Task InitializeAsync() + { + using var client = harness.CreateSecurityClient(); + + harness.ClearOrigins(); + var request = AccessTokenForwardingHarness.FromSession("/api/orders", "session-one"); + request.Headers.TryAddWithoutValidation("Authorization", "Bearer forged-by-the-browser"); + await client.SendAsync(request); + _backendRequest = harness.Backend.LastRequestTo("/api/orders"); + + await client.SendAsync(AccessTokenForwardingHarness.FromSession("/dashboard", "session-one")); + _frontendRequest = harness.Frontend.LastRequestTo("/dashboard"); + + harness.ClearOrigins(); + _rejected = await client.SendAsync(AccessTokenForwardingHarness.FromSession("/api/orders", AccessTokenForwardingHarness.RejectedSession)); + _backendSawTheRejectedSession = harness.Backend.ReceivedAnythingFor("/api/orders"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void should_forward_the_users_access_token_to_the_backend() => + Assert.Equal($"Bearer {AccessTokenForwardingHarness.TokenFor("session-one")}", _backendRequest!.Value("Authorization")); + + [Fact] + public void should_not_forward_an_authorization_header_to_the_frontend() => + Assert.False(_frontendRequest!.Has("Authorization")); + + [Fact] + public void should_refuse_a_session_with_no_obtainable_token() => + Assert.Equal(HttpStatusCode.Unauthorized, _rejected!.StatusCode); + + [Fact] + public void should_not_reach_the_backend_without_a_token() => + Assert.False(_backendSawTheRejectedSession); +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs new file mode 100644 index 00000000..d25e107c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_a_machine_token_targets_another_service.cs @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// A real AuthProxy machine token must not authenticate another service's token-forwarding backend, even with +/// caller-supplied service selection or only one client-credentials candidate and no claim authorization gate. +/// +public class when_a_machine_token_targets_another_service +{ + [Theory] + [InlineData("/api/host", null, "/api")] + [InlineData("/reports/api/header", "machine", "/reports/api")] + [InlineData("/reports/api/query?service=machine", null, "/reports/api")] + public async Task should_reject_the_token_without_reaching_the_other_backend(string path, string? service, string routePrefix) + { + await using var harness = new MachineHarness(routePrefix); + using var client = harness.CreateSecurityClient(); + var token = harness.Services.GetRequiredService().CreateToken( + new ConfiguredClientCredentialsService("machine", routePrefix, new Uri($"{harness.Frontend.BaseUrl}/verify")), + "machine-client", + AccessTokenForwardingHarness.TenantId); + + // Prove the bearer token and the real authentication handler work for its own service first. + var controlPath = $"{routePrefix}/control"; + using var control = new HttpRequestMessage(HttpMethod.Get, controlPath); + control.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + control.Headers.Host = "machine.example.test"; + control.Headers.TryAddWithoutValidation(Headers.ServiceId, "machine"); + using var controlResponse = await client.SendAsync(control); + Assert.Equal(HttpStatusCode.OK, controlResponse.StatusCode); + Assert.NotNull(harness.Frontend.LastRequestTo(controlPath)); + + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + request.Headers.Host = "reporting.example.test"; + if (service is not null) + { + request.Headers.TryAddWithoutValidation(Headers.ServiceId, service); + } + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(harness.Backend.ReceivedAnythingFor(path.Split('?')[0])); + } + + sealed class MachineHarness(string routePrefix) : AccessTokenForwardingHarness + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:reporting:Hosts:0"] = "reporting.example.test", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:PathPrefix"] = "/reports", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:Hosts:0"] = "reporting.example.test", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:Backend:BaseUrl"] = Backend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:prefixed:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:prefixed:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + [$"{C.AuthProxy.SectionKey}:Services:prefixed:AccessToken:Scopes:0"] = "api://reporting/access_as_user", + [$"{C.AuthProxy.SectionKey}:Services:machine:Backend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:machine:Frontend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:machine:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:machine:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + [$"{C.AuthProxy.SectionKey}:Services:machine:ClientCredentials:RoutePrefix"] = routePrefix, + })); + builder.ConfigureTestServices(services => services.PostConfigure(options => + { + options.DefaultScheme = ClientCredentialsDefaults.CompositeAuthenticationScheme; + options.DefaultChallengeScheme = ClientCredentialsDefaults.AuthenticationScheme; + })); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs new file mode 100644 index 00000000..21c67878 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_backend_changes_during_token_acquisition.cs @@ -0,0 +1,111 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// A token awaited for the old backend must never be forwarded to a newly configured origin. +/// +public class when_the_backend_changes_during_token_acquisition : IAsyncLifetime +{ + readonly ReloadingHarness _harness = new(); + HttpClient? _client; + HttpRequestMessage? _oldRequest; + Task? _inFlight; + ForwardedRequest? _oldBackendRequest; + ForwardedRequest? _newBackendRequest; + + public async Task InitializeAsync() + { + _client = _harness.CreateSecurityClient(); + _client.Timeout = TimeSpan.FromSeconds(20); + _oldRequest = AccessTokenForwardingHarness.FromSession("/api/old", "blocked-session"); + _inFlight = _client.SendAsync(_oldRequest); + await _harness.Tokens.Started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + var endpoints = _harness.Services.GetRequiredService(); + _ = endpoints.Endpoints; + var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var changed = endpoints.GetChangeToken().RegisterChangeCallback(_ => reloaded.TrySetResult(), null); + var config = (IConfigurationRoot)_harness.Services.GetRequiredService(); + config[$"{C.AuthProxy.SectionKey}:Services:reporting:Backend:BaseUrl"] = _harness.Frontend.BaseUrl; + config[$"{C.AuthProxy.SectionKey}:Services:reporting:AccessToken:Scopes:0"] = "api://new-backend/access_as_user"; + config.Reload(); + await reloaded.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + using var newRequest = AccessTokenForwardingHarness.FromSession("/api/new", "new-session"); + using var newResponse = await _client.SendAsync(newRequest); + newResponse.EnsureSuccessStatusCode(); + _harness.Tokens.Resume.TrySetResult(); + using var oldResponse = await _inFlight; + oldResponse.EnsureSuccessStatusCode(); + + _oldBackendRequest = _harness.Backend.LastRequestTo("/api/old"); + _newBackendRequest = _harness.Frontend.LastRequestTo("/api/new"); + } + + public async Task DisposeAsync() + { + _harness.Tokens.Resume.TrySetResult(); + try + { + if (_inFlight is not null) + { + using var response = await _inFlight; + } + } + finally + { + _oldRequest?.Dispose(); + _client?.Dispose(); + await _harness.DisposeAsync(); + } + } + + [Fact] + public void should_send_the_old_token_only_to_the_old_backend() => + Assert.Equal("Bearer token-for-api://reporting/access_as_user", _oldBackendRequest!.Value("Authorization")); + + [Fact] + public void should_send_the_new_audiences_token_to_the_new_backend() => + Assert.Equal("Bearer token-for-api://new-backend/access_as_user", _newBackendRequest!.Value("Authorization")); + + [Fact] + public void should_never_send_the_in_flight_request_to_the_new_backend() => + Assert.False(_harness.Frontend.ReceivedAnythingFor("/api/old")); + + sealed class ReloadingHarness : AccessTokenForwardingHarness + { + public BlockingTokens Tokens { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureTestServices(services => services.AddSingleton(Tokens)); + } + } + + sealed class BlockingTokens : IUserAccessTokens + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) + { + if (sessionId == "blocked-session") + { + Started.TrySetResult(); + await Resume.Task.WaitAsync(cancellationToken); + } + + return UserAccessTokenResult.Success($"token-for-{accessToken.Scopes.Single()}"); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs new file mode 100644 index 00000000..1886363c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_AccessTokenForwarding/when_the_service_requires_claims.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace Cratis.AuthProxy.Security.for_AccessTokenForwarding; + +/// +/// Token-forwarding routes must still resolve to the service whose claim requirements authorize the request. +/// +public class when_the_service_requires_claims : IAsyncLifetime +{ + readonly ClaimHarness _harness = new(); + HttpResponseMessage? _authorized; + HttpResponseMessage? _denied; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = _harness.CreateSecurityClient(); + using var authorized = AccessTokenForwardingHarness.FromSession("/api/authorized", "claim-session"); + authorized.Headers.TryAddWithoutValidation(HeaderAuthenticationHandler.ClaimsHeader, "role=member;permission=reports"); + _authorized = await client.SendAsync(authorized); + _forwarded = _harness.Backend.LastRequestTo("/api/authorized"); + + using var denied = AccessTokenForwardingHarness.FromSession("/api/denied", "claim-session"); + denied.Headers.TryAddWithoutValidation(HeaderAuthenticationHandler.ClaimsHeader, "role=member"); + _denied = await client.SendAsync(denied); + } + + public async Task DisposeAsync() + { + _authorized?.Dispose(); + _denied?.Dispose(); + await _harness.DisposeAsync(); + } + + [Fact] public void should_authorize_the_caller_satisfying_root_and_service_requirements() => Assert.Equal(HttpStatusCode.OK, _authorized!.StatusCode); + [Fact] public void should_forward_the_authorized_callers_bearer_token() => Assert.Equal($"Bearer {AccessTokenForwardingHarness.TokenFor("claim-session")}", _forwarded!.Value("Authorization")); + [Fact] public void should_deny_a_caller_missing_the_service_claim() => Assert.Equal(HttpStatusCode.Forbidden, _denied!.StatusCode); + [Fact] public void should_not_forward_the_denied_request() => Assert.False(_harness.Backend.ReceivedAnythingFor("/api/denied")); + + sealed class ClaimHarness : AccessTokenForwardingHarness + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Authorization:RequiredClaims:0:Claim"] = "role", + [$"{C.AuthProxy.SectionKey}:Authorization:RequiredClaims:0:AnyOf:0"] = "member", + [$"{C.AuthProxy.SectionKey}:Services:reporting:Authorization:RequiredClaims:0:Claim"] = "permission", + [$"{C.AuthProxy.SectionKey}:Services:reporting:Authorization:RequiredClaims:0:AnyOf:0"] = "reports", + })); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs new file mode 100644 index 00000000..71b1d822 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingHarness.cs @@ -0,0 +1,174 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A running AuthProxy in front of a service whose backend receives the signed-in user's access token, with separate +/// recording origins for the backend and the frontend. +/// +/// +/// The session cookie and the identity provider are stood in for: a request carrying +/// is treated as a cookie session holding that token session, and the stand-in mints +/// a token naming it, or refuses for . What is under test is the request path: that the +/// forwarding stage runs in the proxy pipeline at all, that it replaces what the caller sent, and that it refuses +/// rather than forwards when no token is available. +/// +public class AccessTokenForwardingHarness : WebApplicationFactory +{ + /// The request header naming the token session of a simulated cookie session. + public const string TokenSessionHeader = "X-Security-Spec-Token-Session"; + + /// A token session for which no token can be obtained. + public const string RejectedSession = "rejected-session"; + + /// The tenant every request resolves to. + public const string TenantId = "44444444-4444-4444-4444-444444444444"; + + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + /// + /// Initializes a new instance of the class. + /// + public AccessTokenForwardingHarness() + { + Directory.CreateDirectory(_pagesPath); + File.WriteAllText(Path.Combine(_pagesPath, WellKnownPageNames.SelectProvider), "Select Provider"); + + Backend = RecordingBackend.Start().GetAwaiter().GetResult(); + Frontend = RecordingBackend.Start().GetAwaiter().GetResult(); + } + + /// Gets the origin of the service's backend. + public RecordingBackend Backend { get; } + + /// Gets the origin of the service's frontend. + public RecordingBackend Frontend { get; } + + /// + /// Gets the token the stand-in provider issues for a token session. + /// + /// The token session. + /// The token. + public static string TokenFor(string session) => $"user-token-for-{session}"; + + /// + /// Builds a request from a signed-in cookie session. + /// + /// The path and query to request. + /// The token session the cookie session holds. + /// The request. + public static HttpRequestMessage FromSession(string pathAndQuery, string session) + { + var request = SecurityHarness.Authenticated(HttpMethod.Get, pathAndQuery, SecurityHarness.UniqueUser("token-forwarding")); + request.Headers.TryAddWithoutValidation(TokenSessionHeader, session); + return request; + } + + /// + /// Forgets what both origins received. + /// + public void ClearOrigins() + { + Backend.Clear(); + Frontend.Clear(); + } + + /// + /// Creates a client that surfaces redirects as responses rather than following them. + /// + /// A configured . + public HttpClient CreateSecurityClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + Backend.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Frontend.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + if (Directory.Exists(_pagesPath)) + { + Directory.Delete(_pagesPath, recursive: true); + } + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder + .UseEnvironment("Production") + .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:reporting:Backend:BaseUrl"] = Backend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:reporting:Frontend:BaseUrl"] = Frontend.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:reporting:ResolveIdentityDetails"] = "false", + [$"{C.AuthProxy.SectionKey}:Services:reporting:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + [$"{C.AuthProxy.SectionKey}:Services:reporting:AccessToken:Scopes:0"] = "api://reporting/access_as_user", + + [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, + + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId, + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", + })) + .ConfigureTestServices(services => + { + services + .AddAuthentication(HeaderAuthenticationHandler.Scheme) + .AddScheme(HeaderAuthenticationHandler.Scheme, _ => { }); + services.AddSingleton(); + services.AddSingleton(); + }); + } + + sealed class StandInUserAccessTokens : IUserAccessTokens + { + public Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) => + Task.FromResult(sessionId == RejectedSession + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected) + : UserAccessTokenResult.Success(TokenFor(sessionId))); + } + + sealed class SimulatedCookieSessionStartupFilter : IStartupFilter + { + public Action Configure(Action next) => + app => + { + app.Use(async (context, proceed) => + { + var session = context.Request.Headers[TokenSessionHeader].ToString(); + context.Request.Headers.Remove(TokenSessionHeader); + if (session.Length > 0) + { + context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = CookieAuthenticationDefaults.AuthenticationScheme; + context.Items["Cratis.AuthProxy.TokenSession"] = session; + } + + await proceed(); + }); + + next(app); + }; + } +} diff --git a/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs new file mode 100644 index 00000000..4c0e76ee --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/AccessTokenForwardingSpecCollection.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Shares one across the access-token forwarding specs. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class AccessTokenForwardingSpecCollection : ICollectionFixture +{ + /// The collection name every access-token forwarding spec joins. + public const string Name = "AccessTokenForwarding"; +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs new file mode 100644 index 00000000..d36a222c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/given/an_access_token_validator.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator.given; + +public class an_access_token_validator : Specification +{ + protected C.Authentication _authentication; + protected C.Service _service; + protected ValidateOptionsResult _result; + + void Establish() + { + _authentication = new() { OidcProviders = [new() { Name = "Workforce", Authority = "https://login.example.com", ClientId = "client-id" }] }; + _service = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting/" }, + AccessToken = new() { Scopes = ["api://reporting/access_as_user"] }, + }; + } + + protected void Validate() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_authentication); + _result = new AccessTokenConfigurationValidator(monitor).Validate(null, new C.AuthProxy { Services = new Dictionary { ["reporting"] = _service } }); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs new file mode 100644 index 00000000..e185e968 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_no_oidc_provider_is_configured.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_no_oidc_provider_is_configured : given.an_access_token_validator +{ + void Establish() => _authentication.OidcProviders = []; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs new file mode 100644 index 00000000..91489787 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_audience.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_an_audience : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Provider = "workforce"; + + void Because() => Validate(); + + [Fact] void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs new file mode 100644 index 00000000..50d01601 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_an_unknown_provider.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_an_unknown_provider : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Provider = "Partners"; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs new file mode 100644 index 00000000..29c236b2 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_access_token_names_no_audience.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_access_token_names_no_audience : given.an_access_token_validator +{ + void Establish() => _service.AccessToken!.Scopes = []; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs new file mode 100644 index 00000000..027519ee --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenConfigurationValidator/when_the_service_has_no_backend.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenConfigurationValidator; + +public class when_the_service_has_no_backend : given.an_access_token_validator +{ + void Establish() => _service.Backend = null; + + void Because() => Validate(); + + [Fact] void should_fail() => _result.Failed.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs new file mode 100644 index 00000000..f9c73fb5 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/given/a_forwarding_middleware.cs @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging.Abstractions; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware.given; + +/// +/// The middleware in front of a request that the route table has sent to the backend of a service declaring an +/// access token, from a caller signed in with the session cookie. +/// +public class a_forwarding_middleware : Specification +{ + protected IUserAccessTokens _tokens; + protected C.ServiceAccessToken _accessToken; + protected DefaultHttpContext _context; + protected bool _forwarded; + protected AccessTokenForwardingMiddleware _middleware; + protected string _authorizationPolicy = "default"; + protected string _endpoint = ReverseProxy.MicroserviceReverseProxyConfigProvider.BackendEndpoint; + protected string? _destinationBinding = "bound-destination"; + protected IReadOnlyList _availableDestinations = [new("bound-destination")]; + protected IReadOnlyList _allDestinations = [new("bound-destination")]; + + void Establish() + { + _accessToken = new() { Scopes = ["api://reporting/access_as_user"] }; + _tokens = Substitute.For(); + _tokens.GetFor("session-id", Arg.Any(), Arg.Any()).Returns(UserAccessTokenResult.Success("user-access-token")); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user")], "Cookies")) + }; + _context.Items[AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = CookieAuthenticationDefaults.AuthenticationScheme; + _context.Items[UserTokenSessions.HttpContextItemKey] = "session-id"; + _context.Request.Headers.Authorization = "Bearer something-the-browser-sent"; + + _middleware = new( + _ => + { + _forwarded = true; + return Task.CompletedTask; + }, + NullLogger.Instance); + } + + protected Task Invoke() + { + var metadata = new Dictionary + { + [ReverseProxy.MicroserviceReverseProxyConfigProvider.ServiceMetadataKey] = "reporting", + [ReverseProxy.MicroserviceReverseProxyConfigProvider.EndpointMetadataKey] = _endpoint, + [ReverseProxy.MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey] = JsonSerializer.Serialize(_accessToken), + }; + if (_destinationBinding is not null) + { + metadata[ReverseProxy.MicroserviceReverseProxyConfigProvider.DestinationMetadataKey] = _destinationBinding; + } + + var cluster = new ClusterModel( + new ClusterConfig + { + ClusterId = "reporting-cluster", + Metadata = metadata, + }, + new HttpMessageInvoker(new SocketsHttpHandler())); + var route = new RouteModel(new RouteConfig { RouteId = "route", AuthorizationPolicy = _authorizationPolicy }, null, HttpTransformer.Empty); + var feature = Substitute.For(); + feature.Route.Returns(route); + feature.Cluster.Returns(cluster); + feature.AvailableDestinations.Returns(_availableDestinations); + feature.AllDestinations.Returns(_allDestinations); + _context.Features.Set(feature); + + return _middleware.InvokeAsync(_context, _tokens); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs new file mode 100644 index 00000000..0665f5cc --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_bearer_authenticated_caller_calls_the_backend.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_a_bearer_authenticated_caller_calls_the_backend : given.a_forwarding_middleware +{ + void Establish() => _context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] = Authentication.ClientCredentialsDefaults.AuthenticationScheme; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_keep_the_callers_own_authorization() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer something-the-browser-sent"); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs new file mode 100644 index 00000000..80eb3b39 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_a_signed_in_user_calls_the_backend.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_a_signed_in_user_calls_the_backend : given.a_forwarding_middleware +{ + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_request_the_audience_from_the_selected_cluster() => _tokens.Received(1).GetFor("session-id", Arg.Is(_ => _.Scopes.SequenceEqual(_accessToken.Scopes)), Arg.Any()); + [Fact] void should_replace_the_authorization_header_with_the_users_access_token() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer user-access-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs new file mode 100644 index 00000000..22496c7a --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_all_destinations_include_a_stale_binding.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_all_destinations_include_a_stale_binding : given.a_forwarding_middleware +{ + void Establish() => _allDestinations = [new("bound-destination"), new("stale-destination")]; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs new file mode 100644 index 00000000..cc46d00b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_destination_is_available.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_no_destination_is_available : given.a_forwarding_middleware +{ + void Establish() => _availableDestinations = []; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_request() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs new file mode 100644 index 00000000..5f3134dd --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_no_token_can_be_obtained.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_no_token_can_be_obtained : given.a_forwarding_middleware +{ + void Establish() => _tokens.GetFor("session-id", Arg.Any(), Arg.Any()).Returns(UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected)); + + Task Because() => Invoke(); + + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); + [Fact] void should_refuse_it_as_unauthenticated() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs new file mode 100644 index 00000000..1f3a8cf0 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_only_available_destinations_have_a_stale_binding.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_only_available_destinations_have_a_stale_binding : given.a_forwarding_middleware +{ + void Establish() => _availableDestinations = [new("stale-destination")]; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs new file mode 100644 index 00000000..6d68add9 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_destination_binding_is_missing.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_destination_binding_is_missing : given.a_forwarding_middleware +{ + void Establish() => _destinationBinding = null; + + Task Because() => Invoke(); + + [Fact] void should_refuse_the_unbound_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_a_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs new file mode 100644 index 00000000..e1ff0fec --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_new_policy_is_published_before_its_destinations.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Cratis.AuthProxy.ReverseProxy; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_new_policy_is_published_before_its_destinations : given.a_forwarding_middleware +{ + MicroserviceReverseProxyConfigProvider _provider; + string _oldDestinationId; + + void Establish() + { + var config = new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://old-backend/" }, + AccessToken = new C.ServiceAccessToken { Scopes = ["old-audience"] }, + }, + }, + }; + Action reload = null!; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(config); + monitor.OnChange(Arg.Do>(listener => reload = listener)); + _provider = new(monitor, Substitute.For>()); + var oldCluster = _provider.GetConfig().Clusters.Single(); + _oldDestinationId = oldCluster.Destinations!.Single().Key; + var oldDestination = new DestinationState(_oldDestinationId, new DestinationModel(oldCluster.Destinations.Single().Value)); + + config.Services["Reporting"].Backend!.BaseUrl = "https://new-backend/"; + config.Services["Reporting"].AccessToken!.Scopes = ["new-audience"]; + reload(config, Options.DefaultName); + var newCluster = _provider.GetConfig().Clusters.Single(); + + // Freeze the snapshot at YARP's reload interval: the new Cluster.Model has been published, + // but DestinationsState still holds the old origin. No timing or real reload race is needed. + _accessToken = JsonSerializer.Deserialize(newCluster.Metadata![MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey])!; + _destinationBinding = newCluster.Metadata[MicroserviceReverseProxyConfigProvider.DestinationMetadataKey]; + _availableDestinations = [oldDestination]; + _allDestinations = [oldDestination]; + } + + Task Because() => Invoke(); + + void Destroy() => _provider.Dispose(); + + [Fact] void should_capture_a_different_binding() => (_destinationBinding != _oldDestinationId).ShouldBeTrue(); + [Fact] void should_refuse_the_mixed_snapshot() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status503ServiceUnavailable); + [Fact] void should_not_obtain_the_new_audiences_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_never_forward_to_the_old_backend() => _forwarded.ShouldBeFalse(); + [Fact] void should_not_replace_the_authorization_header() => _context.Request.Headers.Authorization.ToString().ShouldEqual("Bearer something-the-browser-sent"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs new file mode 100644 index 00000000..0515373e --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_goes_to_the_frontend.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_request_goes_to_the_frontend : given.a_forwarding_middleware +{ + void Establish() => _endpoint = ReverseProxy.MicroserviceReverseProxyConfigProvider.FrontendEndpoint; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs new file mode 100644 index 00000000..efd670bd --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_request_is_on_an_anonymous_path.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_request_is_on_an_anonymous_path : given.a_forwarding_middleware +{ + void Establish() => _authorizationPolicy = ReverseProxy.MicroserviceReverseProxyConfigProvider.AnonymousAuthorizationPolicy; + + Task Because() => Invoke(); + + [Fact] void should_forward_the_request() => _forwarded.ShouldBeTrue(); + [Fact] void should_obtain_no_user_token() => _tokens.DidNotReceive().GetFor(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs new file mode 100644 index 00000000..eb0f0e8b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_AccessTokenForwardingMiddleware/when_the_session_holds_no_token_session.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_AccessTokenForwardingMiddleware; + +public class when_the_session_holds_no_token_session : given.a_forwarding_middleware +{ + void Establish() => _context.Items.Remove(UserTokenSessions.HttpContextItemKey); + + Task Because() => Invoke(); + + [Fact] void should_not_forward_the_request() => _forwarded.ShouldBeFalse(); + [Fact] void should_refuse_it_as_unauthenticated() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs new file mode 100644 index 00000000..71db2167 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/given/user_access_tokens.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens.given; + +/// +/// over a real token store, a stand-in token endpoint, and one OIDC provider +/// authenticated by client secret. +/// +public class user_access_tokens : a_user_token_store +{ + protected const string Scheme = "workforce"; + protected const string TokenEndpointUrl = "https://login.example.com/tenant/oauth2/v2.0/token"; + + protected TokenEndpoint _endpoint; + protected C.OidcProvider _provider; + protected OpenIdConnectOptions _options; + protected IOidcClientAssertions _assertions; + protected UserAccessTokens _tokens; + protected C.ServiceAccessToken _accessToken; + protected string _sessionId; + + async Task Establish() + { + _endpoint = new TokenEndpoint(); + _provider = new() { Name = "Workforce", Authority = "https://login.example.com/tenant/v2.0", ClientId = "client-id", ClientSecret = "client-secret" }; + _options = new() + { + ClientId = "client-id", + ClientSecret = "client-secret", + Configuration = new OpenIdConnectConfiguration { TokenEndpoint = TokenEndpointUrl }, + }; + + var oidcOptions = Substitute.For>(); + oidcOptions.Get(Scheme).Returns(_ => _options); + var authentication = Substitute.For>(); + authentication.CurrentValue.Returns(_ => new C.Authentication { OidcProviders = [_provider] }); + _assertions = Substitute.For(); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(UserAccessTokens.HttpClientName).Returns(_ => new HttpClient(_endpoint, disposeHandler: false)); + + _tokens = new(_store, oidcOptions, authentication, _assertions, httpClientFactory, _time, NullLogger.Instance); + _accessToken = new() { Scopes = ["api://reporting/access_as_user"] }; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + } + + protected Task Get() => _tokens.GetFor(_sessionId, _accessToken, CancellationToken.None); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs new file mode 100644 index 00000000..12023569 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_refresh_rejection_backoff_expires.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_a_refresh_rejection_backoff_expires : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Establish() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + await Get(); + _time.Advance(TimeSpan.FromSeconds(30)); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 3600); + } + + async Task Because() => _result = await Get(); + + [Fact] void should_retry_the_provider() => _endpoint.Received.Count.ShouldEqual(2); + [Fact] void should_forward_the_new_token() => _result.Token.ShouldEqual("renewed-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs new file mode 100644 index 00000000..180004ec --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_a_short_lived_token_is_cached.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_a_short_lived_token_is_cached : given.user_access_tokens +{ + UserAccessTokenResult _reused; + UserAccessTokenResult _renewed; + int _callsBeforeRenewal; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("short-token", 60); + await Get(); + _time.Advance(TimeSpan.FromSeconds(29)); + _reused = await Get(); + _callsBeforeRenewal = _endpoint.Received.Count; + _time.Advance(TimeSpan.FromSeconds(1)); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 60); + _renewed = await Get(); + } + + [Fact] void should_reuse_the_token_before_its_half_life() => _reused.Token.ShouldEqual("short-token"); + [Fact] void should_not_refresh_on_each_request() => _callsBeforeRenewal.ShouldEqual(1); + [Fact] void should_renew_at_the_half_life() => _renewed.Token.ShouldEqual("renewed-token"); + [Fact] void should_refresh_only_twice() => _endpoint.Received.Count.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs new file mode 100644 index 00000000..a355e1b0 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_concurrent_requests_receive_a_refresh_rejection.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_concurrent_requests_receive_a_refresh_rejection : given.user_access_tokens +{ + UserAccessTokenResult[] _results; + + async Task Because() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async cancellationToken => + { + entered.SetResult(); + await release.Task.WaitAsync(cancellationToken); + return TokenEndpoint.Error("invalid_grant"); + }; + var first = Get(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + var second = Get(); + release.SetResult(); + _results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_redeem_only_once() => _endpoint.Received.Count.ShouldEqual(1); + [Fact] void should_refuse_both_requests() => _results.All(_ => _.Failure == UserAccessTokenFailure.RefreshTokenRejected).ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs new file mode 100644 index 00000000..2b26bd32 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_logout_occurs_during_a_rotating_refresh.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_logout_occurs_during_a_rotating_refresh : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserAccessTokenResult _replayed; + UserTokenSession? _session; + + async Task Because() + { + var issued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deliver = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async token => + { + issued.SetResult(); + await deliver.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System, token); + return TokenEndpoint.Bearer("access-token", 3600, "rotated-refresh-token"); + }; + var request = Get(); + await issued.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + await _store.Remove(_sessionId, CancellationToken.None); + deliver.SetResult(); + _result = await request.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(_sessionId, CancellationToken.None); + _replayed = await Get(); + } + + [Fact] void should_not_resurrect_the_session() => _session.ShouldBeNull(); + [Fact] void should_not_forward_the_in_flight_token() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_refuse_a_replayed_cookie_session() => _replayed.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_cache_the_access_token() => _cache.Written.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs new file mode 100644 index 00000000..503a25ff --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_obtaining_a_token_for_an_audience.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_obtaining_a_token_for_an_audience : given.user_access_tokens +{ + UserAccessTokenResult _first; + UserAccessTokenResult _second; + + async Task Because() + { + _first = await Get(); + _second = await Get(); + } + + [Fact] void should_obtain_the_token() => _first.Token.ShouldEqual("access-token"); + [Fact] void should_redeem_the_refresh_token() => _endpoint.Received[0]["grant_type"].ShouldEqual("refresh_token"); + [Fact] void should_present_the_sessions_refresh_token() => _endpoint.Received[0]["refresh_token"].ShouldEqual("refresh-token"); + [Fact] void should_ask_for_the_audiences_scopes() => _endpoint.Received[0]["scope"].ShouldEqual("api://reporting/access_as_user"); + [Fact] void should_authenticate_with_the_client_secret() => _endpoint.Received[0]["client_secret"].ShouldEqual("client-secret"); + [Fact] void should_identify_the_client() => _endpoint.Received[0]["client_id"].ShouldEqual("client-id"); + [Fact] void should_answer_again_from_the_cache() => _second.Token.ShouldEqual("access-token"); + [Fact] void should_call_the_provider_once() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs new file mode 100644 index 00000000..bb24ec34 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_one_audience_requires_consent.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_one_audience_requires_consent : given.user_access_tokens +{ + UserAccessTokenResult _refused; + UserAccessTokenResult _other; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + _refused = await Get(); + _endpoint.Answer = () => TokenEndpoint.Bearer("other-audience-token", 3600); + _other = await _tokens.GetFor(_sessionId, new() { Scopes = ["api://other/access_as_user"] }, CancellationToken.None); + } + + [Fact] void should_refuse_only_the_affected_audience() => _refused.Succeeded.ShouldBeFalse(); + [Fact] void should_obtain_a_token_for_the_other_audience() => _other.Token.ShouldEqual("other-audience-token"); + [Fact] void should_redeem_the_original_refresh_token_for_the_other_audience() => _endpoint.Received[1]["refresh_token"].ShouldEqual("refresh-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs new file mode 100644 index 00000000..edef0bcc --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_browser_cancels_a_rotating_refresh.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_browser_cancels_a_rotating_refresh : given.user_access_tokens +{ + Exception? _error; + UserTokenSession? _session; + UserAccessTokenResult _next; + + async Task Because() + { + var issued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deliver = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _endpoint.AnswerAsync = async token => + { + issued.SetResult(); + await deliver.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System, token); + return TokenEndpoint.Bearer("rotated-access-token", 3600, "rotated-refresh-token"); + }; + using var browser = new CancellationTokenSource(); + var request = _tokens.GetFor(_sessionId, _accessToken, browser.Token); + await issued.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + await browser.CancelAsync(); + _error = await Catch.Exception(async () => await request); + deliver.SetResult(); + _next = await Get().WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_let_the_browser_stop_waiting() => (_error is OperationCanceledException).ShouldBeTrue(); + [Fact] void should_persist_the_rotated_refresh_token() => _session!.RefreshToken.ShouldEqual("rotated-refresh-token"); + [Fact] void should_cache_the_completed_access_token() => _next.Token.ShouldEqual("rotated-access-token"); + [Fact] void should_not_redeem_the_obsolete_refresh_token_again() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs new file mode 100644 index 00000000..9c4e6c0b --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_cached_token_is_about_to_expire.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_cached_token_is_about_to_expire : given.user_access_tokens +{ + UserAccessTokenResult _renewed; + + async Task Because() + { + await Get(); + _endpoint.Answer = () => TokenEndpoint.Bearer("renewed-token", 3600); + _time.Advance(TimeSpan.FromMinutes(59.5)); + _renewed = await Get(); + } + + [Fact] void should_renew_it_before_it_expires() => _renewed.Token.ShouldEqual("renewed-token"); + [Fact] void should_call_the_provider_again() => _endpoint.Received.Count.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs new file mode 100644 index 00000000..ccc0c019 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_answers_without_a_bearer_token.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_answers_without_a_bearer_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Json(System.Net.HttpStatusCode.OK, """{"access_token":"token","token_type":"DPoP","expires_in":3600}"""); + _result = await Get(); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.ProviderUnavailable); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs new file mode 100644 index 00000000..6ce47505 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_authenticates_with_a_client_assertion.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_authenticates_with_a_client_assertion : given.user_access_tokens +{ + async Task Because() + { + _provider.ClientSecret = string.Empty; + _provider.ClientCredential = new() { Source = C.OidcClientCredentialSource.ManagedIdentity }; + _options.ClientSecret = null; + _assertions.Create(Scheme, Arg.Any(), TokenEndpointUrl, Arg.Any()).Returns("signed-assertion"); + await Get(); + } + + [Fact] void should_send_the_assertion() => _endpoint.Received[0]["client_assertion"].ShouldEqual("signed-assertion"); + [Fact] void should_declare_a_jwt_assertion() => _endpoint.Received[0]["client_assertion_type"].ShouldEqual("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + [Fact] void should_send_no_client_secret() => _endpoint.Received[0].ContainsKey("client_secret").ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs new file mode 100644 index 00000000..be0a9d1f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_cannot_be_reached.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_cannot_be_reached : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserTokenSession? _session; + + async Task Because() + { + _endpoint.Answer = () => throw new HttpRequestException("connection refused"); + _result = await Get(); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.ProviderUnavailable); + [Fact] void should_keep_the_refresh_token_for_a_later_attempt() => _session.ShouldNotBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs new file mode 100644 index 00000000..4758f9ce --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rejects_the_refresh_token.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_rejects_the_refresh_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + UserAccessTokenResult _second; + UserTokenSession? _session; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Error("invalid_grant"); + _result = await Get(); + _second = await Get(); + _session = await _store.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); + [Fact] void should_reject_the_next_request_without_redeeming_again() => _second.Failure.ShouldEqual(UserAccessTokenFailure.RefreshTokenRejected); + [Fact] void should_call_the_provider_only_once() => _endpoint.Received.Count.ShouldEqual(1); + [Fact] void should_keep_the_session_for_other_audiences() => _session.ShouldNotBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs new file mode 100644 index 00000000..5a84d3ab --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_provider_rotates_the_refresh_token.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_provider_rotates_the_refresh_token : given.user_access_tokens +{ + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("access-token", 3600, refreshToken: "rotated-refresh-token"); + await Get(); + _accessToken = new() { Scopes = ["api://billing/access_as_user"] }; + await Get(); + } + + [Fact] void should_present_the_rotated_refresh_token_next() => _endpoint.Received[1]["refresh_token"].ShouldEqual("rotated-refresh-token"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs new file mode 100644 index 00000000..81063d67 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_service_requires_another_provider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_service_requires_another_provider : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() + { + _accessToken.Provider = "Partners"; + _result = await Get(); + } + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.WrongProvider); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs new file mode 100644 index 00000000..f1cc3ebd --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_the_session_holds_no_refresh_token.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_the_session_holds_no_refresh_token : given.user_access_tokens +{ + UserAccessTokenResult _result; + + async Task Because() => _result = await _tokens.GetFor("unknown-session", _accessToken, CancellationToken.None); + + [Fact] void should_fail() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs new file mode 100644 index 00000000..5d7eafce --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserAccessTokens/when_two_audiences_are_requested.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.AccessTokens.given; + +namespace Cratis.AuthProxy.AccessTokens.for_UserAccessTokens; + +public class when_two_audiences_are_requested : given.user_access_tokens +{ + UserAccessTokenResult _reporting; + UserAccessTokenResult _billing; + + async Task Because() + { + _endpoint.Answer = () => TokenEndpoint.Bearer("reporting-token", 3600); + _reporting = await Get(); + _accessToken = new() { Resource = "https://billing.example.com" }; + _endpoint.Answer = () => TokenEndpoint.Bearer("billing-token", 3600); + _billing = await Get(); + } + + [Fact] void should_keep_a_token_per_audience() => (_reporting.Token, _billing.Token).ShouldEqual(("reporting-token", "billing-token")); + [Fact] void should_send_the_resource_indicator() => _endpoint.Received[1]["resource"].ShouldEqual("https://billing.example.com"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs new file mode 100644 index 00000000..06ba4799 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_cookie_token_session.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions.given; + +public class a_cookie_token_session : for_UserAccessTokens.given.user_access_tokens +{ + protected CookieAuthenticationOptions _cookie; + ServiceProvider _services; + + async Task Establish() + { + _config.Session.Lifetime = TimeSpan.FromMinutes(1); + _config.Session.SlidingExpiration = true; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.Session.SectionKey}:Lifetime"] = "00:01:00", + [$"{C.Session.SectionKey}:SlidingExpiration"] = "true", + }); + builder.AddIngressAuthentication(); + builder.Services.AddSingleton(_store); + _services = builder.Services.BuildServiceProvider(); + _cookie = _services.GetRequiredService>().Get("Cookies"); + } + + protected async Task ValidateCookie() + { + var properties = new AuthenticationProperties(); + properties.Items[UserTokenSessions.PropertiesKey] = _sessionId; + var context = new DefaultHttpContext { RequestServices = _services }; + context.Request.Path = "/frontend"; + await _cookie.Events.ValidatePrincipal(new CookieValidatePrincipalContext( + context, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + _cookie, + new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("Cookies")), properties, "Cookies"))); + } + + void Destroy() => _services.Dispose(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs new file mode 100644 index 00000000..75a0f045 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/given/a_sign_in_redeeming_its_code.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions.given; + +/// +/// An OIDC sign-in whose token response has just arrived, in a deployment where one service forwards user tokens. +/// +public class a_sign_in_redeeming_its_code : Specification +{ + protected C.AuthProxy _config; + protected IUserTokenStore _store; + protected AuthenticationProperties _properties; + protected IAuthenticationService _authentication; + protected TokenResponseReceivedContext _context; + + void Establish() + { + _config = new C.AuthProxy + { + Services = new Dictionary + { + ["reporting"] = new() { AccessToken = new() { Scopes = ["api://reporting/.default"] } }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_ => _config); + + _store = Substitute.For(); + _store.Create(Arg.Any(), Arg.Any()).Returns("session-id"); + + _authentication = Substitute.For(); + var services = new ServiceCollection() + .AddLogging() + .AddSingleton(monitor) + .AddSingleton(_store) + .AddSingleton(_authentication) + .BuildServiceProvider(); + + _properties = new AuthenticationProperties(); + _context = new TokenResponseReceivedContext( + new DefaultHttpContext { RequestServices = services }, + new AuthenticationScheme("workforce", null, typeof(OpenIdConnectHandler)), + new OpenIdConnectOptions(), + new ClaimsPrincipal(), + _properties) + { + TokenEndpointResponse = new OpenIdConnectMessage { AccessToken = "sign-in-access-token", IdToken = "id-token", RefreshToken = "refresh-token" }, + }; + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs new file mode 100644 index 00000000..d1e1af9a --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_link_callback_redeems_its_code.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_link_callback_redeems_its_code : given.a_sign_in_redeeming_its_code +{ + void Establish() => _properties.Items[Links.LinkMiddleware.LinkModePropertyKey] = "true"; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing_for_an_identity_that_is_not_signed_in() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs new file mode 100644 index 00000000..5fb8b9e6 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_session_is_signed_out.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_session_is_signed_out : Specification +{ + IUserTokenStore _store; + DefaultHttpContext _httpContext; + + void Establish() + { + _store = Substitute.For(); + _httpContext = new DefaultHttpContext { RequestServices = new ServiceCollection().AddSingleton(_store).BuildServiceProvider() }; + + var properties = new AuthenticationProperties(); + properties.Items[UserTokenSessions.PropertiesKey] = "session-id"; + var ticket = new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("Cookies")), properties, "Cookies"); + UserTokenSessions.Remember(new CookieValidatePrincipalContext(_httpContext, new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), new CookieAuthenticationOptions(), ticket)); + } + + Task Because() => UserTokenSessions.Forget(new CookieSigningOutContext( + _httpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions(), + new AuthenticationProperties(), + new CookieOptions())); + + [Fact] void should_remove_the_token_session() => _store.Received(1).Remove("session-id", Arg.Any()); + [Fact] void should_forget_it_for_the_rest_of_the_request() => UserTokenSessions.Of(_httpContext).ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs new file mode 100644 index 00000000..3e3564a7 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_redeems_its_code.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_sign_in_redeems_its_code : given.a_sign_in_redeeming_its_code +{ + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_not_store_tokens_before_validation_and_ticket_handlers_succeed() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); + [Fact] void should_not_create_a_cookie_token_session_yet() => _properties.Items.ContainsKey(UserTokenSessions.PropertiesKey).ShouldBeFalse(); + [Fact] void should_put_no_tokens_on_the_session() => _properties.GetTokens().ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs new file mode 100644 index 00000000..c07c2ac1 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sign_in_replaces_an_existing_session.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_sign_in_replaces_an_existing_session : given.a_sign_in_redeeming_its_code +{ + async Task Because() + { + _context.HttpContext.Request.Headers.Cookie = ".AuthProxy=old-cookie"; + var previousProperties = new AuthenticationProperties(); + previousProperties.Items[UserTokenSessions.PropertiesKey] = "previous-session"; + _authentication.AuthenticateAsync(_context.HttpContext, "Cookies").Returns(AuthenticateResult.Success( + new AuthenticationTicket(new ClaimsPrincipal(), previousProperties, "Cookies"))); + await UserTokenSessions.Capture(_context); + await UserTokenSessions.Complete(new CookieSigningInContext( + _context.HttpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions { Cookie = new CookieBuilder { Name = ".AuthProxy" } }, + new ClaimsPrincipal(), + _properties, + new CookieOptions())); + } + + [Fact] void should_remove_the_previous_token_session() => _store.Received(1).Remove("previous-session", CancellationToken.None); + [Fact] void should_keep_only_the_new_identifier() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs new file mode 100644 index 00000000..91a8433f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_sliding_cookie_is_active_without_forwarding.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_sliding_cookie_is_active_without_forwarding : given.a_cookie_token_session +{ + UserAccessTokenResult _result; + + async Task Because() + { + for (var request = 0; request < 3; request++) + { + _time.Advance(TimeSpan.FromSeconds(40)); + await ValidateCookie(); + } + + _time.Advance(TimeSpan.FromSeconds(40)); + _result = await Get(); + } + + [Fact] void should_still_forward_the_users_token_after_the_original_lifetime() => _result.Token.ShouldEqual("access-token"); + [Fact] void should_redeem_only_for_the_forwarding_request() => _endpoint.Received.Count.ShouldEqual(1); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs new file mode 100644 index 00000000..ba76da0f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_a_validated_sign_in_issues_its_cookie.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_a_validated_sign_in_issues_its_cookie : given.a_sign_in_redeeming_its_code +{ + async Task Because() + { + await UserTokenSessions.Capture(_context); + await UserTokenSessions.Complete(new CookieSigningInContext( + _context.HttpContext, + new AuthenticationScheme("Cookies", null, typeof(CookieAuthenticationHandler)), + new CookieAuthenticationOptions(), + new ClaimsPrincipal(), + _properties, + new CookieOptions())); + } + + [Fact] void should_store_the_refresh_token_after_validation() => _store.Received(1).Create(new UserTokenSession("workforce", "refresh-token"), Arg.Any()); + [Fact] void should_put_only_the_session_identifier_on_the_cookie() => _properties.Items[UserTokenSessions.PropertiesKey].ShouldEqual("session-id"); + [Fact] void should_put_no_tokens_on_the_cookie() => _properties.GetTokens().ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs new file mode 100644 index 00000000..ddc8c92c --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_an_absolute_cookie_is_active_without_forwarding.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_an_absolute_cookie_is_active_without_forwarding : given.a_cookie_token_session +{ + UserAccessTokenResult _result; + + async Task Establish() + { + _cookie.SlidingExpiration = false; + _config.Session.SlidingExpiration = false; + _sessionId = await _store.Create(new(Scheme, "refresh-token"), CancellationToken.None); + } + + async Task Because() + { + _time.Advance(TimeSpan.FromSeconds(40)); + await ValidateCookie(); + _time.Advance(TimeSpan.FromSeconds(40)); + _result = await Get(); + } + + [Fact] void should_not_extend_the_absolute_token_session() => _result.Failure.ShouldEqual(UserAccessTokenFailure.NoRefreshToken); + [Fact] void should_not_call_the_provider() => _endpoint.Received.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs new file mode 100644 index 00000000..548ae699 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_no_service_forwards_user_tokens.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_no_service_forwards_user_tokens : given.a_sign_in_redeeming_its_code +{ + void Establish() => _config.Services["reporting"].AccessToken = null; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); + [Fact] void should_leave_the_session_untouched() => _properties.Items.ContainsKey(UserTokenSessions.PropertiesKey).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs new file mode 100644 index 00000000..838f060f --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenSessions/when_the_provider_issues_no_refresh_token.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenSessions; + +public class when_the_provider_issues_no_refresh_token : given.a_sign_in_redeeming_its_code +{ + void Establish() => _context.TokenEndpointResponse.RefreshToken = null; + + Task Because() => UserTokenSessions.Capture(_context); + + [Fact] void should_keep_nothing() => _store.DidNotReceive().Create(Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs new file mode 100644 index 00000000..aa638977 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_rotation_arrives_after_logout.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_rotation_arrives_after_logout : given.a_user_token_store +{ + UserTokenSession? _session; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + await _store.Remove(sessionId, CancellationToken.None); + await _store.Update(sessionId, new("workforce", "rotated-token"), CancellationToken.None); + _session = await _store.Get(sessionId, CancellationToken.None); + } + + [Fact] void should_not_recreate_the_session() => _session.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs new file mode 100644 index 00000000..c778a3a6 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_read_with_another_key_ring.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.DataProtection; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_read_with_another_key_ring : given.a_user_token_store +{ + string _sessionId; + UserTokenSession? _read; + + async Task Because() + { + _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + var other = new UserTokenStore(_cache, new EphemeralDataProtectionProvider(), config, _time); + _read = await other.Get(_sessionId, CancellationToken.None); + } + + [Fact] void should_treat_it_as_absent() => _read.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs new file mode 100644 index 00000000..b7206263 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_removed.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_removed : given.a_user_token_store +{ + string _sessionId; + UserTokenSession? _session; + CachedUserAccessToken? _accessToken; + bool _rejected; + + async Task Establish() + { + _sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + await _store.SetRefreshRejected(_sessionId, "refused-audience", _time.GetUtcNow().AddSeconds(30), CancellationToken.None); + await _store.SetAccessToken(_sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + } + + async Task Because() + { + await _store.Remove(_sessionId, CancellationToken.None); + _session = await _store.Get(_sessionId, CancellationToken.None); + _rejected = await _store.IsRefreshRejected(_sessionId, "refused-audience", CancellationToken.None); + _accessToken = await _store.GetAccessToken(_sessionId, "audience", CancellationToken.None); + } + + [Fact] void should_forget_the_refresh_rejections() => _rejected.ShouldBeFalse(); + [Fact] void should_remove_every_cached_entry() => _cache.Written.Keys.All(_ => _cache.Get(_) is null).ShouldBeTrue(); + [Fact] void should_forget_the_refresh_token() => _session.ShouldBeNull(); + [Fact] void should_forget_the_access_tokens_obtained_for_it() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs new file mode 100644 index 00000000..c647e88e --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_session_is_stored.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_session_is_stored : given.a_user_token_store +{ + const string RefreshToken = "refresh-token-value"; + + string _sessionId; + UserTokenSession? _read; + bool _cacheHoldsTheRefreshTokenInClear; + + async Task Because() + { + _sessionId = await _store.Create(new("workforce", RefreshToken), CancellationToken.None); + _read = await _store.Get(_sessionId, CancellationToken.None); + + _cacheHoldsTheRefreshTokenInClear = _cache.Written + .Any(_ => _.Key.Contains(_sessionId, StringComparison.Ordinal) + || Encoding.UTF8.GetString(_.Value).Contains(RefreshToken, StringComparison.Ordinal)); + } + + [Fact] void should_read_back_the_session() => _read.ShouldEqual(new UserTokenSession("workforce", RefreshToken)); + [Fact] void should_identify_it_unguessably() => _sessionId.Length.ShouldEqual(43); + [Fact] void should_keep_neither_the_refresh_token_nor_the_session_identifier_in_clear() => _cacheHoldsTheRefreshTokenInClear.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs new file mode 100644 index 00000000..ec5c0d63 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_a_sliding_session_is_accessed.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_a_sliding_session_is_accessed : given.a_user_token_store +{ + UserTokenSession? _active; + UserTokenSession? _expired; + + void Establish() => _config.Session.SlidingExpiration = true; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + _active = await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(12)); + _expired = await _store.Get(sessionId, CancellationToken.None); + } + + [Fact] void should_extend_the_deadline_when_accessed() => _active.ShouldNotBeNull(); + [Fact] void should_expire_after_an_idle_lifetime() => _expired.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs new file mode 100644 index 00000000..7c99cd56 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_absolute_session_is_updated.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_an_absolute_session_is_updated : given.a_user_token_store +{ + UserTokenSession? _before; + UserTokenSession? _after; + CachedUserAccessToken? _accessToken; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + _time.Advance(TimeSpan.FromHours(11)); + await _store.Update(sessionId, new("workforce", "rotated-token"), CancellationToken.None); + await _store.SetAccessToken(sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(2), _time.GetUtcNow().AddHours(1.5)), _time.GetUtcNow().AddHours(1.5), CancellationToken.None); + _before = await _store.Get(sessionId, CancellationToken.None); + _time.Advance(TimeSpan.FromHours(1)); + _after = await _store.Get(sessionId, CancellationToken.None); + _accessToken = await _store.GetAccessToken(sessionId, "audience", CancellationToken.None); + } + + [Fact] void should_keep_the_rotation_until_the_original_deadline() => _before!.RefreshToken.ShouldEqual("rotated-token"); + [Fact] void should_not_extend_the_absolute_session_deadline() => _after.ShouldBeNull(); + [Fact] void should_not_retain_an_access_token_past_the_session_deadline() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs new file mode 100644 index 00000000..a539439a --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_an_access_token_arrives_for_an_ended_session.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_an_access_token_arrives_for_an_ended_session : given.a_user_token_store +{ + CachedUserAccessToken? _accessToken; + + async Task Because() + { + await _store.SetAccessToken("ended-session", "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + _accessToken = await _store.GetAccessToken("ended-session", "audience", CancellationToken.None); + } + + [Fact] void should_keep_nothing() => _accessToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs new file mode 100644 index 00000000..72bfa1ee --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/for_UserTokenStore/when_logout_races_with_an_access_token_write.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.for_UserTokenStore; + +public class when_logout_races_with_an_access_token_write : given.a_user_token_store +{ + UserTokenSession? _session; + byte[]? _remainingToken; + + async Task Because() + { + var sessionId = await _store.Create(new("workforce", "refresh-token"), CancellationToken.None); + var writing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _cache.BeforeSet = async key => + { + if (key.EndsWith(":audience", StringComparison.Ordinal)) + { + writing.SetResult(); + await finish.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + }; + var write = _store.SetAccessToken(sessionId, "audience", new("access-token", _time.GetUtcNow().AddHours(1), _time.GetUtcNow().AddMinutes(59)), _time.GetUtcNow().AddMinutes(59), CancellationToken.None); + await writing.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + var logout = _store.Remove(sessionId, CancellationToken.None); + finish.SetResult(); + await Task.WhenAll(write, logout).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _session = await _store.Get(sessionId, CancellationToken.None); + _remainingToken = await _cache.GetAsync(_cache.Written.Keys.Single(_ => _.EndsWith(":audience", StringComparison.Ordinal))); + } + + [Fact] void should_remove_the_session() => _session.ShouldBeNull(); + [Fact] void should_not_leave_a_token_written_after_removal() => _remainingToken.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs b/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs new file mode 100644 index 00000000..81c2fd62 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/ManualTime.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A whose clock only moves when a spec moves it. +/// +/// The starting time. +public class ManualTime(DateTimeOffset now) : TimeProvider +{ + DateTimeOffset _now = now; + + /// + public override DateTimeOffset GetUtcNow() => _now; + + /// + /// Moves the clock forward. + /// + /// How far to move it. + public void Advance(TimeSpan by) => _now = _now.Add(by); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs new file mode 100644 index 00000000..fa6dec40 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/RecordingDistributedCache.cs @@ -0,0 +1,102 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Caching.Distributed; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// An in-memory cache whose absolute and sliding expiration follow the spec's clock, recording every write. +/// +/// The clock shared with the token store. +public class RecordingDistributedCache(TimeProvider timeProvider) : IDistributedCache +{ + readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + + /// + /// Gets every value written, by key. + /// + public ConcurrentDictionary Written { get; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets a write barrier for concurrency specs. + /// + public Func? BeforeSet { get; set; } + + /// + public byte[]? Get(string key) => ReadEntry(key); + + /// + public Task GetAsync(string key, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + return Task.FromResult(ReadEntry(key)); + } + + /// + public void Refresh(string key) => ReadEntry(key); + + /// + public Task RefreshAsync(string key, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + ReadEntry(key); + return Task.CompletedTask; + } + + /// + public void Remove(string key) => _entries.TryRemove(key, out _); + + /// + public Task RemoveAsync(string key, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + _entries.TryRemove(key, out _); + return Task.CompletedTask; + } + + /// + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => WriteEntry(key, value, options); + + /// + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (BeforeSet is { } beforeSet) + { + await beforeSet(key); + } + + WriteEntry(key, value, options); + } + + byte[]? ReadEntry(string key) + { + if (!_entries.TryGetValue(key, out var entry)) + { + return null; + } + + var now = timeProvider.GetUtcNow(); + if ((entry.AbsoluteExpiration is { } absolute && now >= absolute) + || (entry.SlidingExpiration is { } sliding && now >= entry.LastAccess + sliding)) + { + _entries.TryRemove(key, out _); + return null; + } + + _entries[key] = entry with { LastAccess = now }; + return entry.Value; + } + + void WriteEntry(string key, byte[] value, DistributedCacheEntryOptions options) + { + var now = timeProvider.GetUtcNow(); + var absolute = options.AbsoluteExpirationRelativeToNow is { } relative ? now + relative : options.AbsoluteExpiration; + Written[key] = value; + _entries[key] = new(value, absolute, options.SlidingExpiration, now); + } + + sealed record Entry(byte[] Value, DateTimeOffset? AbsoluteExpiration, TimeSpan? SlidingExpiration, DateTimeOffset LastAccess); +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs new file mode 100644 index 00000000..91f53341 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/TokenEndpoint.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A stand-in identity-provider token endpoint that records each form it receives and answers with whatever the spec +/// queued. +/// +public class TokenEndpoint : HttpMessageHandler +{ + /// + /// Gets the forms received, in order. + /// + public List> Received { get; } = []; + + /// + /// Gets or sets how the endpoint answers the next request. + /// + public Func Answer { get; set; } = () => Bearer("access-token", 3600); + + /// + /// Gets or sets an asynchronous response for concurrency specs. + /// + public Func>? AnswerAsync { get; set; } + + /// + /// Builds a successful bearer token answer. + /// + /// The access token. + /// The lifetime in seconds. + /// An optional rotated refresh token. + /// The response. + public static HttpResponseMessage Bearer(string accessToken, int expiresIn, string? refreshToken = null) + { + var rotation = refreshToken is null ? string.Empty : $$""","refresh_token":"{{refreshToken}}" """.TrimEnd(); + return Json(HttpStatusCode.OK, $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{rotation}}}"""); + } + + /// + /// Builds an OAuth error answer. + /// + /// The error code. + /// The response. + public static HttpResponseMessage Error(string error) => Json(HttpStatusCode.BadRequest, $$"""{"error":"{{error}}"}"""); + + /// + /// Builds a JSON answer. + /// + /// The status code. + /// The body. + /// The response. + public static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") }; + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var form = await request.Content!.ReadAsStringAsync(cancellationToken); + Received.Add(Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(form).ToDictionary(_ => _.Key, _ => _.Value.ToString(), StringComparer.Ordinal)); + return AnswerAsync is { } answer ? await answer(cancellationToken) : Answer(); + } +} diff --git a/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs new file mode 100644 index 00000000..75898615 --- /dev/null +++ b/Source/AuthProxy.Specs/AccessTokens/given/a_user_token_store.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.DataProtection; + +namespace Cratis.AuthProxy.AccessTokens.given; + +/// +/// A real on an in-memory cache and an ephemeral key ring. +/// +public class a_user_token_store : Specification +{ + protected RecordingDistributedCache _cache; + protected ManualTime _time; + protected UserTokenStore _store; + protected C.AuthProxy _config; + + void Establish() + { + _time = new ManualTime(new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero)); + _cache = new RecordingDistributedCache(_time); + _config = new C.AuthProxy(); + var config = Substitute.For>(); + config.CurrentValue.Returns(_ => _config); + _store = new UserTokenStore(_cache, new EphemeralDataProtectionProvider(), config, _time); + } +} diff --git a/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs new file mode 100644 index 00000000..646f2477 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_registering_user_token_capture.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions; + +public class when_registering_user_token_capture : Specification +{ + OpenIdConnectOptions _oidc; + CookieAuthenticationOptions _cookie; + + void Establish() + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Workforce", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.microsoftonline.com/tenant/v2.0", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-id", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientSecret"] = "client-secret", + }); + + builder.AddIngressAuthentication(); + var services = builder.Services.BuildServiceProvider(); + _oidc = services.GetRequiredService>().Get("workforce"); + _cookie = services.GetRequiredService>().Get(CookieAuthenticationDefaults.AuthenticationScheme); + } + + [Fact] void should_capture_the_token_response() => _oidc.Events.OnTokenResponseReceived.ShouldNotBeNull(); + [Fact] void should_not_save_tokens_in_the_session_cookie() => _oidc.SaveTokens.ShouldBeFalse(); + [Fact] void should_forget_token_sessions_on_sign_out() => _cookie.Events.OnSigningOut.ShouldNotBeNull(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs new file mode 100644 index 00000000..9a21d35c --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_ClientCredentialsServiceResolver/when_the_selected_proxy_service_cannot_be_resolved.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; + +namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsServiceResolver; + +public class when_the_selected_proxy_service_cannot_be_resolved : Specification +{ + ClientCredentialsServiceResolver _resolver; + DefaultHttpContext _context; + + void Establish() + { + var config = new C.AuthProxy + { + Services = new Dictionary + { + ["machine"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://machine.test/" }, + ClientCredentials = new C.ServiceClientCredentials(), + }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(config); + _resolver = new(monitor, Substitute.For>()); + _context = new DefaultHttpContext(); + _context.Request.Path = "/api/orders"; + var route = new RouteModel( + new RouteConfig { RouteId = "removed-route", ClusterId = "removed-backend-cluster" }, + new ClusterState("removed-backend-cluster"), + HttpTransformer.Default); + _context.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(route), "proxied")); + } + + [Theory] + [InlineData(null, null)] + [InlineData("machine", null)] + [InlineData(null, "?service=machine")] + public void should_not_fall_back_to_a_client_credentials_candidate(string? header, string? query) + { + _context.Request.Headers[Headers.ServiceId] = header; + _context.Request.QueryString = new QueryString(query); + _resolver.TryResolveForRequest(_context.Request, out _).ShouldBeFalse(); + } +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs index 9eed9b1d..862c7b0b 100644 --- a/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs +++ b/Source/AuthProxy.Specs/ReverseProxy/for_ActivityTimeout/given/a_streaming_deployment.cs @@ -3,6 +3,7 @@ using System.Net.WebSockets; using System.Text; +using Cratis.AuthProxy.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -62,6 +63,7 @@ protected async Task StartWith(TimeSpan activityTimeout) }, }; }); + builder.AddIngressAuthentication(); builder.SetupReverseProxy(); _proxy = builder.Build(); diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs new file mode 100644 index 00000000..cc3ec720 --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_token_forwarding_binding_changes.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using Yarp.ReverseProxy.Configuration; + +namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider; + +public class when_a_token_forwarding_binding_changes : Specification +{ + MicroserviceReverseProxyConfigProvider _provider; + Action _reload; + C.AuthProxy _config; + IProxyConfig _original; + IProxyConfig _newPolicy; + IProxyConfig _newAddress; + + void Establish() + { + _config = new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://old-backend/" }, + AccessToken = new C.ServiceAccessToken { Scopes = ["old-audience"], Resource = "old-resource", Provider = "old-provider" }, + }, + }, + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(_config); + monitor.OnChange(Arg.Do>(listener => _reload = listener)); + _provider = new(monitor, Substitute.For>()); + _original = _provider.GetConfig(); + } + + void Because() + { + _config.Services["Reporting"].AccessToken!.Scopes[0] = "new-audience"; + _reload(_config, Options.DefaultName); + _newPolicy = _provider.GetConfig(); + _config.Services["Reporting"].Backend!.BaseUrl = "https://new-backend/"; + _reload(_config, Options.DefaultName); + _newAddress = _provider.GetConfig(); + } + + void Destroy() => _provider.Dispose(); + + C.ServiceAccessToken OriginalPolicy() => JsonSerializer.Deserialize(_original.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey])!; + + [Fact] void should_keep_the_cluster_id_convention() => _original.Clusters.Single().ClusterId.ShouldEqual("reporting-backend-cluster"); + [Fact] void should_keep_the_cluster_id_when_only_the_policy_changes() => _newPolicy.Clusters.Single().ClusterId.ShouldEqual(_original.Clusters.Single().ClusterId); + [Fact] void should_keep_the_cluster_id_when_only_the_address_changes() => _newAddress.Clusters.Single().ClusterId.ShouldEqual(_newPolicy.Clusters.Single().ClusterId); + [Fact] void should_version_the_destination_when_only_the_policy_changes() => (_original.Clusters.Single().Destinations!.Single().Key != _newPolicy.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_version_the_destination_when_only_the_address_changes() => (_newPolicy.Clusters.Single().Destinations!.Single().Key != _newAddress.Clusters.Single().Destinations!.Single().Key).ShouldBeTrue(); + [Fact] void should_bind_the_original_policy_to_its_destination() => _original.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_original.Clusters.Single().Destinations!.Single().Key); + [Fact] void should_bind_the_new_policy_to_its_destination() => _newPolicy.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_newPolicy.Clusters.Single().Destinations!.Single().Key); + [Fact] void should_bind_the_new_address_to_its_destination() => _newAddress.Clusters.Single().Metadata![MicroserviceReverseProxyConfigProvider.DestinationMetadataKey].ShouldEqual(_newAddress.Clusters.Single().Destinations!.Single().Key); + [Fact] void should_keep_the_original_scopes_immutable() => OriginalPolicy().Scopes.ShouldContainOnly("old-audience"); + [Fact] void should_keep_the_original_resource() => OriginalPolicy().Resource.ShouldEqual("old-resource"); + [Fact] void should_keep_the_original_provider() => OriginalPolicy().Provider.ShouldEqual("old-provider"); + [Fact] void should_bind_all_routes_to_the_selected_cluster() => _newAddress.Routes.All(_ => _.ClusterId == _newAddress.Clusters.Single().ClusterId).ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs new file mode 100644 index 00000000..7265b70f --- /dev/null +++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_building_clusters.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Yarp.ReverseProxy.Configuration; + +namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider; + +/// +/// Each cluster names the service and endpoint it belongs to, so the proxy pipeline can tell a service's backend +/// from its frontend without parsing cluster identifiers. +/// +public class when_building_clusters : Specification +{ + IReadOnlyList _clusters; + + void Establish() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.AuthProxy + { + Services = new Dictionary + { + ["Reporting"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://reporting-api/" }, + Frontend = new C.ServiceEndpoint { BaseUrl = "http://reporting-web/" }, + }, + }, + }); + _clusters = new MicroserviceReverseProxyConfigProvider(monitor, Substitute.For>()).GetConfig().Clusters; + } + + ClusterConfig Cluster(string id) => _clusters.Single(_ => _.ClusterId == id); + + [Fact] void should_name_the_service_of_the_backend() => Cluster("reporting-backend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.ServiceMetadataKey].ShouldEqual("reporting"); + [Fact] void should_mark_the_backend() => Cluster("reporting-backend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.EndpointMetadataKey].ShouldEqual(MicroserviceReverseProxyConfigProvider.BackendEndpoint); + [Fact] void should_mark_the_frontend() => Cluster("reporting-frontend-cluster").Metadata![MicroserviceReverseProxyConfigProvider.EndpointMetadataKey].ShouldEqual(MicroserviceReverseProxyConfigProvider.FrontendEndpoint); +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs b/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs new file mode 100644 index 00000000..297195b6 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenConfigurationValidator.cs @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Validates every service's at startup. +/// +/// The authentication configuration, naming the OIDC providers tokens can come from. +public class AccessTokenConfigurationValidator(IOptionsMonitor authentication) : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var providers = authentication.CurrentValue.OidcProviders; + var failures = options.Services + .Where(_ => _.Value.AccessToken is not null) + .SelectMany(_ => Problems(_.Key, _.Value, providers)) + .ToArray(); + + return failures.Length == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + static IEnumerable Problems(string serviceName, C.Service service, IList providers) + { + var accessToken = service.AccessToken!; + + if (service.Backend is null) + { + yield return $"Service '{serviceName}' declares an AccessToken but has no Backend to forward it to."; + } + + if (accessToken.Scopes.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(accessToken.Resource)) + { + yield return $"Service '{serviceName}': AccessToken needs Scopes or a Resource naming the backend's audience."; + } + + if (providers.Count == 0) + { + yield return $"Service '{serviceName}' declares an AccessToken, but no OIDC provider is configured to obtain it from."; + } + else if (!string.IsNullOrWhiteSpace(accessToken.Provider) + && !providers.Any(_ => string.Equals(OidcProviderScheme.FromName(_.Name), OidcProviderScheme.FromName(accessToken.Provider), StringComparison.Ordinal))) + { + yield return $"Service '{serviceName}': AccessToken.Provider '{accessToken.Provider}' is not a configured OIDC provider."; + } + } +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs new file mode 100644 index 00000000..71fb53b5 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddleware.cs @@ -0,0 +1,103 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.ReverseProxy; +using Microsoft.AspNetCore.Authentication.Cookies; +using Yarp.ReverseProxy.Model; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Puts the signed-in user's access token for a service's audience on requests proxied to that service's backend. +/// +/// +/// Runs in the reverse-proxy pipeline, after a route and cluster are selected, so it knows exactly which service and +/// endpoint the request goes to. It acts only on requests authenticated by the AuthProxy session cookie and routed +/// to the backend of a service that declares . Machine callers authenticated by a +/// bearer token keep their own Authorization header, and anonymous paths are forwarded as they are. +/// When no token can be obtained the request is refused with 401, never forwarded without the token +/// the backend expects. A configuration reload snapshot whose destinations do not match the token policy is refused +/// with 503 before obtaining a token. +/// +/// The next middleware in the proxy pipeline. +/// The for diagnostics. +public class AccessTokenForwardingMiddleware( + RequestDelegate next, + ILogger logger) +{ + /// + /// Handles the request. + /// + /// The current . + /// The obtaining tokens. + /// A representing the asynchronous operation. + public async Task InvokeAsync(HttpContext context, IUserAccessTokens tokens) + { + var proxy = context.Features.Get(); + if (proxy is null + || proxy.Route.Config.AuthorizationPolicy == MicroserviceReverseProxyConfigProvider.AnonymousAuthorizationPolicy + || !TryGetAccessToken(proxy, out var serviceName, out var accessToken) + || !IsSessionRequest(context)) + { + await next(context); + return; + } + + // YARP publishes the cluster model and destinations separately. Never acquire a token for a policy + // paired with destinations from another binding, even in the short interval during a reload. + if (!HasBoundDestinations(proxy)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + + var result = UserTokenSessions.Of(context) is { } sessionId + ? await tokens.GetFor(sessionId, accessToken, context.RequestAborted) + : UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken); + + if (!result.Succeeded) + { + logger.AccessTokenUnavailable(serviceName, result.Failure); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + context.Request.Headers.Authorization = $"Bearer {result.Token}"; + await next(context); + } + + static bool TryGetAccessToken(IReverseProxyFeature proxy, out string serviceName, out C.ServiceAccessToken accessToken) + { + serviceName = string.Empty; + accessToken = default!; + + var metadata = proxy.Cluster.Config.Metadata; + if (metadata is null + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.ServiceMetadataKey, out var key) + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.EndpointMetadataKey, out var endpoint) + || endpoint != MicroserviceReverseProxyConfigProvider.BackendEndpoint + || !metadata.TryGetValue(MicroserviceReverseProxyConfigProvider.AccessTokenMetadataKey, out var policy)) + { + return false; + } + + serviceName = key; + accessToken = JsonSerializer.Deserialize(policy)!; + return true; + } + + static bool HasBoundDestinations(IReverseProxyFeature proxy) => + proxy.Cluster.Config.Metadata!.TryGetValue(MicroserviceReverseProxyConfigProvider.DestinationMetadataKey, out var destinationId) + && proxy.AvailableDestinations.Count > 0 + && proxy.AllDestinations.Count > 0 + && proxy.AvailableDestinations.All(destination => string.Equals(destination.DestinationId, destinationId, StringComparison.Ordinal)) + && proxy.AllDestinations.All(destination => string.Equals(destination.DestinationId, destinationId, StringComparison.Ordinal)); + + static bool IsSessionRequest(HttpContext context) => + context.User.Identity?.IsAuthenticated == true + && string.Equals( + context.Items[Authentication.AuthenticationServiceCollectionExtensions.SelectedSchemeItemKey] as string, + CookieAuthenticationDefaults.AuthenticationScheme, + StringComparison.Ordinal); +} diff --git a/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs new file mode 100644 index 00000000..6a948b1b --- /dev/null +++ b/Source/AuthProxy/AccessTokens/AccessTokenForwardingMiddlewareLogging.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +internal static partial class AccessTokenForwardingMiddlewareLogging +{ + [LoggerMessage(LogLevel.Warning, "No access token could be obtained for the signed-in user for service {Service} ({Reason}); the request is refused with 401")] + internal static partial void AccessTokenUnavailable(this ILogger logger, string service, UserAccessTokenFailure reason); +} diff --git a/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs new file mode 100644 index 00000000..a33d01a4 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/CachedUserAccessToken.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents an access token held for a user and audience until shortly before it expires. +/// +/// The access token. +/// When the provider said the token expires. +/// When the token must be renewed. +public sealed record CachedUserAccessToken(string Value, DateTimeOffset ExpiresAt, DateTimeOffset RenewAt); diff --git a/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs b/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs new file mode 100644 index 00000000..13eeba9e --- /dev/null +++ b/Source/AuthProxy/AccessTokens/IUserAccessTokens.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines a system that obtains access tokens for a signed-in user, per audience. +/// +public interface IUserAccessTokens +{ + /// + /// Gets an access token for the user's session and the audience a service declares. + /// + /// The token session identifier carried by the user's session. + /// The audience the service declares. + /// The for the operation. + /// The token, or why none could be obtained. + Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/AccessTokens/IUserTokenStore.cs b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs new file mode 100644 index 00000000..87636468 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/IUserTokenStore.cs @@ -0,0 +1,86 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines the server-side store of users' refresh tokens and the access tokens obtained with them. +/// +/// +/// Entries are keyed by an unguessable session identifier that only the encrypted session cookie carries, so the +/// tokens themselves never reach the browser. +/// +public interface IUserTokenStore +{ + /// + /// Stores a new session and returns its identifier. + /// + /// The session to store. + /// The for the operation. + /// The identifier to keep in the session cookie. + Task Create(UserTokenSession session, CancellationToken cancellationToken); + + /// + /// Gets a session. + /// + /// The session identifier. + /// The for the operation. + /// The session, or when it is unknown or has expired. + Task Get(string sessionId, CancellationToken cancellationToken); + + /// + /// Replaces a session, for example after the provider rotated the refresh token. + /// + /// The session identifier. + /// The session. + /// The for the operation. + /// A representing the asynchronous operation. + Task Update(string sessionId, UserTokenSession session, CancellationToken cancellationToken); + + /// + /// Removes a session and every access token obtained for it. + /// + /// The session identifier. + /// The for the operation. + /// A representing the asynchronous operation. + Task Remove(string sessionId, CancellationToken cancellationToken); + + /// + /// Gets a cached access token for a session and audience. + /// + /// The session identifier. + /// The audience key. + /// The for the operation. + /// The cached token, or when there is none. + Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken); + + /// + /// Caches an access token for a session and audience until it is due for renewal. + /// + /// The session identifier. + /// The audience key. + /// The token. + /// When the token must no longer be handed out. + /// The for the operation. + /// A representing the asynchronous operation. + Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken); + + /// + /// Gets whether a refresh for this session and audience was recently rejected. + /// + /// The session identifier. + /// The audience key. + /// The for the operation. + /// Whether the audience must wait before retrying its refresh. + Task IsRefreshRejected(string sessionId, string audience, CancellationToken cancellationToken); + + /// + /// Records a refused refresh for a session and audience until it may be retried. + /// + /// The session identifier. + /// The audience key. + /// When a refresh may be retried. + /// The for the operation. + /// A representing the asynchronous operation. + Task SetRefreshRejected(string sessionId, string audience, DateTimeOffset retryAt, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs b/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs new file mode 100644 index 00000000..fe7cc955 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokenFailure.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Defines why no access token could be obtained for the signed-in user. +/// +public enum UserAccessTokenFailure +{ + /// + /// A token was obtained. + /// + None = 0, + + /// + /// The session has no refresh token held for it: the provider issued none (no offline_access), + /// the session began before token forwarding was configured, or another AuthProxy instance holds it. + /// + NoRefreshToken = 1, + + /// + /// The user signed in with a provider other than the one the service names. + /// + WrongProvider = 2, + + /// + /// The provider refused the refresh token; the user has to sign in again. + /// + RefreshTokenRejected = 3, + + /// + /// The provider could not be reached or answered with something other than a bearer token. + /// + ProviderUnavailable = 4, +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs b/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs new file mode 100644 index 00000000..5f525322 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokenResult.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents the outcome of obtaining an access token for the signed-in user. +/// +/// The access token, when one was obtained. +/// Why no token was obtained, or . +public sealed record UserAccessTokenResult(string? Token, UserAccessTokenFailure Failure) +{ + /// + /// Gets a value indicating whether a token was obtained. + /// + public bool Succeeded => Failure == UserAccessTokenFailure.None && Token is not null; + + /// + /// Creates a successful result. + /// + /// The access token. + /// The result. + public static UserAccessTokenResult Success(string token) => new(token, UserAccessTokenFailure.None); + + /// + /// Creates a failed result. + /// + /// Why no token was obtained. + /// The result. + public static UserAccessTokenResult Failed(UserAccessTokenFailure failure) => new(null, failure); +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokens.cs b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs new file mode 100644 index 00000000..409dd903 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokens.cs @@ -0,0 +1,267 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents an implementation of that redeems the session's refresh token at the +/// identity provider's token endpoint (RFC 6749 section 6) for each audience, and caches the result until shortly +/// before it expires. +/// +/// +/// AuthProxy authenticates the refresh the way it authenticates the sign-in: with the provider's client secret, or +/// with the client assertion of its certificate or federated credential. A provider that rotates refresh tokens gets +/// the new one stored. Refreshes for one session are serialized within this instance, so concurrent requests do +/// not race to redeem the same refresh token. +/// +/// The holding refresh and access tokens. +/// The OIDC handler options, for each provider's client and metadata. +/// The authentication configuration, for each provider's client credential. +/// The for providers authenticated by assertion. +/// The for the token endpoint. +/// The . +/// The for diagnostics. +public sealed class UserAccessTokens( + IUserTokenStore store, + IOptionsMonitor oidcOptions, + IOptionsMonitor authentication, + IOidcClientAssertions clientAssertions, + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, + ILogger logger) : IUserAccessTokens +{ + /// + /// The name of the HTTP client used for the token endpoint. + /// + public const string HttpClientName = "Cratis.AuthProxy.UserAccessTokens"; + + /// + /// How long before its expiry a cached access token is renewed. + /// + public static readonly TimeSpan RenewalMargin = TimeSpan.FromMinutes(1); + + /// + /// The lifetime assumed for an access token whose response states none. + /// + public static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5); + + static readonly TimeSpan _refreshRejectionBackoff = TimeSpan.FromSeconds(30); + + readonly SemaphoreSlim[] _refreshLocks = [.. Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1))]; + + /// + public async Task GetFor(string sessionId, C.ServiceAccessToken accessToken, CancellationToken cancellationToken) + { + var session = await store.Get(sessionId, cancellationToken); + if (session is null) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken); + } + + if (!string.IsNullOrWhiteSpace(accessToken.Provider) + && !string.Equals(OidcProviderScheme.FromName(accessToken.Provider), session.Scheme, StringComparison.Ordinal)) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.WrongProvider); + } + + var audience = AudienceKey(session.Scheme, accessToken); + if (await CachedResult(sessionId, audience, cancellationToken) is { } cached) + { + return cached; + } + + // The caller may stop waiting, but a started redemption must finish and persist any rotation. + return await RefreshUnderLock(sessionId, audience, accessToken).WaitAsync(cancellationToken); + } + + static string AudienceKey(string scheme, C.ServiceAccessToken accessToken) + { + var scopes = string.Join(' ', accessToken.Scopes.Select(_ => _.Trim()).Where(_ => _.Length > 0).Order(StringComparer.Ordinal)); + var material = $"{scheme}\n{scopes}\n{accessToken.Resource.Trim()}"; + return WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(material))); + } + + static string? StringProperty(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + static TimeSpan Lifetime(JsonElement root) => + root.TryGetProperty("expires_in", out var value) && value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt64(out var seconds) && seconds > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null, + JsonValueKind.String => long.TryParse(value.GetString(), NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) && seconds > 0 ? TimeSpan.FromSeconds(seconds) : null, + _ => null + } is { } lifetime + ? lifetime + : DefaultLifetime; + + async Task RefreshUnderLock(string sessionId, string audience, C.ServiceAccessToken accessToken) + { + using var operation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var cancellationToken = operation.Token; + var refreshLock = _refreshLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_refreshLocks.Length]; + var acquired = false; + try + { + await refreshLock.WaitAsync(cancellationToken); + acquired = true; + + // Another request for this session may have refreshed while this one waited. + if (await CachedResult(sessionId, audience, cancellationToken) is { } refreshed) + { + return refreshed; + } + + var session = await store.Get(sessionId, cancellationToken); + return session is null + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) + : await Refresh(sessionId, session, audience, accessToken, cancellationToken); + } + catch (OperationCanceledException) when (operation.IsCancellationRequested) + { + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + finally + { + if (acquired) + { + refreshLock.Release(); + } + } + } + + async Task CachedResult(string sessionId, string audience, CancellationToken cancellationToken) + { + if (await store.GetAccessToken(sessionId, audience, cancellationToken) is { } cached + && cached.RenewAt > timeProvider.GetUtcNow()) + { + return UserAccessTokenResult.Success(cached.Value); + } + + return await store.IsRefreshRejected(sessionId, audience, cancellationToken) + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected) + : null; + } + + async Task Refresh( + string sessionId, + UserTokenSession session, + string audience, + C.ServiceAccessToken accessToken, + CancellationToken cancellationToken) + { + var provider = authentication.CurrentValue.OidcProviders + .FirstOrDefault(_ => string.Equals(OidcProviderScheme.FromName(_.Name), session.Scheme, StringComparison.Ordinal)); + if (provider is null) + { + logger.ProviderNoLongerConfigured(session.Scheme); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + try + { + var options = oidcOptions.Get(session.Scheme); + var tokenEndpoint = await OidcClientAuthentication.TokenEndpointOf(options, cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(await Form(session, provider, options, accessToken, tokenEndpoint, cancellationToken)) + }; + request.Headers.Accept.ParseAdd("application/json"); + + using var response = await httpClientFactory.CreateClient(HttpClientName).SendAsync(request, cancellationToken); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); + var root = document.RootElement; + + if (!response.IsSuccessStatusCode) + { + var error = root.ValueKind == JsonValueKind.Object ? StringProperty(root, "error") : null; + logger.RefreshRefused(session.Scheme, (int)response.StatusCode, error ?? "(none)"); + if (string.Equals(error, "invalid_grant", StringComparison.Ordinal)) + { + // invalid_grant may mean missing consent or resource-specific policy, not a dead session. + await store.SetRefreshRejected(sessionId, audience, timeProvider.GetUtcNow() + _refreshRejectionBackoff, CancellationToken.None); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.RefreshTokenRejected); + } + + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + if (root.ValueKind != JsonValueKind.Object + || StringProperty(root, "access_token") is not { Length: > 0 } token + || !string.Equals(StringProperty(root, "token_type"), "Bearer", StringComparison.OrdinalIgnoreCase)) + { + logger.RefreshAnsweredWithoutBearerToken(session.Scheme); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + + if (StringProperty(root, "refresh_token") is { Length: > 0 } rotated && rotated != session.RefreshToken) + { + await store.Update(sessionId, session with { RefreshToken = rotated }, CancellationToken.None); + } + + var now = timeProvider.GetUtcNow(); + var lifetime = Lifetime(root); + var renewAt = now + lifetime - (lifetime > RenewalMargin * 2 ? RenewalMargin : lifetime / 2); + await store.SetAccessToken(sessionId, audience, new CachedUserAccessToken(token, now + lifetime, renewAt), renewAt, CancellationToken.None); + + // A logout during redemption must also prevent this request from forwarding the new token. + return await store.Get(sessionId, CancellationToken.None) is null + ? UserAccessTokenResult.Failed(UserAccessTokenFailure.NoRefreshToken) + : UserAccessTokenResult.Success(token); + } + catch (Exception exception) when ( + exception is HttpRequestException or JsonException or OidcClientCredentialUnavailable or InvalidOperationException or IOException + || exception is OperationCanceledException) + { + logger.RefreshFailed(session.Scheme, exception); + return UserAccessTokenResult.Failed(UserAccessTokenFailure.ProviderUnavailable); + } + } + + async Task> Form( + UserTokenSession session, + C.OidcProvider provider, + OpenIdConnectOptions options, + C.ServiceAccessToken accessToken, + string tokenEndpoint, + CancellationToken cancellationToken) + { + var form = new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = session.RefreshToken, + ["client_id"] = options.ClientId!, + }; + + var scopes = string.Join(' ', accessToken.Scopes.Select(_ => _.Trim()).Where(_ => _.Length > 0)); + if (scopes.Length > 0) + { + form["scope"] = scopes; + } + + if (!string.IsNullOrWhiteSpace(accessToken.Resource)) + { + form["resource"] = accessToken.Resource.Trim(); + } + + if (provider.UsesClientAssertion) + { + form["client_assertion_type"] = OidcClientAuthentication.JwtBearerAssertionType; + form["client_assertion"] = await clientAssertions.Create(session.Scheme, provider, tokenEndpoint, cancellationToken); + } + else if (!string.IsNullOrEmpty(options.ClientSecret)) + { + form["client_secret"] = options.ClientSecret; + } + + return form; + } +} diff --git a/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs b/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs new file mode 100644 index 00000000..62d1b185 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserAccessTokensLogging.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +internal static partial class UserAccessTokensLogging +{ + [LoggerMessage(LogLevel.Warning, "The OIDC provider {Scheme} a session signed in with is no longer configured; no access token can be obtained for it")] + internal static partial void ProviderNoLongerConfigured(this ILogger logger, string scheme); + + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} refused to refresh an access token with status {StatusCode} and error {Error}")] + internal static partial void RefreshRefused(this ILogger logger, string scheme, int statusCode, string error); + + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} answered a token refresh without a bearer access token")] + internal static partial void RefreshAnsweredWithoutBearerToken(this ILogger logger, string scheme); + + [LoggerMessage(LogLevel.Warning, "Refreshing an access token at OIDC provider {Scheme} failed")] + internal static partial void RefreshFailed(this ILogger logger, string scheme, Exception exception); +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenSession.cs b/Source/AuthProxy/AccessTokens/UserTokenSession.cs new file mode 100644 index 00000000..a40cc936 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSession.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents what AuthProxy keeps server-side for one signed-in session so it can obtain access tokens for the user. +/// +/// The authentication scheme of the OIDC provider the user signed in with. +/// The refresh token the provider issued for the session. +public sealed record UserTokenSession(string Scheme, string RefreshToken); diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessions.cs b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs new file mode 100644 index 00000000..e561cc2f --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSessions.cs @@ -0,0 +1,152 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Links; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Ties the server-side token session to the AuthProxy session cookie: created when a sign-in redeems its +/// authorization code and issues its cookie, found again on each authenticated request, and removed when the session ends. +/// +/// +/// The session cookie carries only an unguessable identifier, inside its encrypted ticket. The refresh token stays +/// in the , and the cookie carries no tokens at all. +/// +public static class UserTokenSessions +{ + /// + /// The authentication-properties item that carries the token session identifier in the session cookie. + /// + public const string PropertiesKey = "Cratis.AuthProxy.TokenSession"; + + /// + /// The key holding the token session identifier of the current request's session. + /// + internal const string HttpContextItemKey = "Cratis.AuthProxy.TokenSession"; + + const string PendingSessionKey = "Cratis.AuthProxy.PendingTokenSession"; + + /// + /// Gets whether any service forwards user access tokens, which is the only case in which refresh tokens are kept. + /// + /// The configuration. + /// when at least one service declares an access token. + public static bool IsForwardingConfigured(C.AuthProxy config) => config.Services.Values.Any(_ => _.AccessToken is not null); + + /// + /// Gets the token session identifier of the current request's session, when it has one. + /// + /// The current . + /// The identifier, or . + public static string? Of(HttpContext context) => context.Items[HttpContextItemKey] as string; + + /// + /// Holds the refresh token on the callback request until sign-in succeeds. + /// + /// The token-response context of the OIDC handler. + /// A representing the asynchronous operation. + internal static Task Capture(TokenResponseReceivedContext context) + { + var services = context.HttpContext.RequestServices; + var config = services.GetRequiredService>().CurrentValue; + if (!IsForwardingConfigured(config) || context.Properties is null) + { + return Task.CompletedTask; + } + + // A link callback authenticates a second identity without signing it in, so it starts no session. + if (context.Properties.Items.TryGetValue(LinkMiddleware.LinkModePropertyKey, out var linkMode) && linkMode == "true") + { + return Task.CompletedTask; + } + + var refreshToken = context.TokenEndpointResponse.RefreshToken; + if (string.IsNullOrEmpty(refreshToken)) + { + services.GetRequiredService() + .CreateLogger(typeof(UserTokenSessions)) + .NoRefreshTokenIssued(context.Scheme.Name); + return Task.CompletedTask; + } + + context.HttpContext.Items[PendingSessionKey] = new UserTokenSession(context.Scheme.Name, refreshToken); + return Task.CompletedTask; + } + + /// + /// Persists the pending token session only when a validated sign-in issues its cookie, replacing any old session. + /// + /// The cookie sign-in context. + /// A representing the asynchronous operation. + internal static async Task Complete(CookieSigningInContext context) + { + var previousSession = Of(context.HttpContext); + if (previousSession is null + && context.Options.Cookie.Name is { } cookieName + && context.HttpContext.Request.Cookies.ContainsKey(cookieName)) + { + var previousTicket = await context.HttpContext.AuthenticateAsync(context.Scheme.Name); + previousTicket.Properties?.Items.TryGetValue(PropertiesKey, out previousSession); + } + + if (previousSession is not null) + { + await context.HttpContext.RequestServices.GetRequiredService().Remove(previousSession, CancellationToken.None); + context.HttpContext.Items.Remove(HttpContextItemKey); + } + + context.Properties.Items.Remove(PropertiesKey); + if (context.HttpContext.Items.Remove(PendingSessionKey, out var pending) && pending is UserTokenSession session) + { + var store = context.HttpContext.RequestServices.GetRequiredService(); + context.Properties.Items[PropertiesKey] = await store.Create(session, CancellationToken.None); + } + } + + /// + /// Makes the token session of an authenticated session cookie available to the rest of the request. + /// + /// The cookie validation context. + internal static void Remember(CookieValidatePrincipalContext context) + { + if (context.Properties.Items.TryGetValue(PropertiesKey, out var sessionId) && !string.IsNullOrEmpty(sessionId)) + { + context.HttpContext.Items[HttpContextItemKey] = sessionId; + } + } + + /// + /// Keeps sliding token retention aligned with a successfully validated cookie, even on non-forwarding routes. + /// + /// The cookie validation context. + /// A representing the asynchronous operation. + internal static async Task RenewRetention(CookieValidatePrincipalContext context) + { + if (context.Principal is not null && context.Options.SlidingExpiration && Of(context.HttpContext) is { } sessionId) + { + await context.HttpContext.RequestServices.GetRequiredService().Get(sessionId, context.HttpContext.RequestAborted); + } + } + + /// + /// Removes the token session of a session that is being signed out. + /// + /// The cookie sign-out context. + /// A representing the asynchronous operation. + internal static async Task Forget(CookieSigningOutContext context) + { + if (Of(context.HttpContext) is not { } sessionId) + { + return; + } + + await context.HttpContext.RequestServices.GetRequiredService().Remove(sessionId, CancellationToken.None); + context.HttpContext.Items.Remove(HttpContextItemKey); + } +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs b/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs new file mode 100644 index 00000000..426fe937 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenSessionsLogging.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.AccessTokens; + +internal static partial class UserTokenSessionsLogging +{ + [LoggerMessage(LogLevel.Warning, "OIDC provider {Scheme} issued no refresh token at sign-in, so services that forward user access tokens will refuse this session. Request the offline_access scope from the provider")] + internal static partial void NoRefreshTokenIssued(this ILogger logger, string scheme); +} diff --git a/Source/AuthProxy/AccessTokens/UserTokenStore.cs b/Source/AuthProxy/AccessTokens/UserTokenStore.cs new file mode 100644 index 00000000..38f8f9c2 --- /dev/null +++ b/Source/AuthProxy/AccessTokens/UserTokenStore.cs @@ -0,0 +1,200 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.AccessTokens; + +/// +/// Represents an implementation of on , with every value +/// encrypted by ASP.NET Core Data Protection. +/// +/// +/// The default cache is in the process's memory, so tokens live and die with the AuthProxy instance that obtained +/// them. Every audience a session obtained a token for is recorded on the session, so removing the session removes +/// them all. +/// +/// The holding the entries. +/// The encrypting them. +/// The session configuration, which bounds how long an entry is kept. +/// The . +public sealed class UserTokenStore( + IDistributedCache cache, + IDataProtectionProvider dataProtection, + IOptionsMonitor session, + TimeProvider timeProvider) : IUserTokenStore +{ + const string KeyPrefix = "Cratis.AuthProxy.UserTokens:"; + + readonly SemaphoreSlim[] _mutationLocks = [.. Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1))]; + readonly IDataProtector _protector = dataProtection.CreateProtector("Cratis.AuthProxy.UserTokens.v1"); + + /// + public async Task Create(UserTokenSession session, CancellationToken cancellationToken) + { + var sessionId = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + var options = SessionEntryOptions(); + await Write(SessionKey(sessionId), new StoredSession(session.Scheme, session.RefreshToken, [], options.AbsoluteExpiration), options, cancellationToken); + return sessionId; + } + + /// + public async Task Get(string sessionId, CancellationToken cancellationToken) => + await Read(SessionKey(sessionId), cancellationToken) is { } stored + ? new UserTokenSession(stored.Scheme, stored.RefreshToken) + : null; + + /// + public async Task Update(string sessionId, UserTokenSession session, CancellationToken cancellationToken) + { + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try + { + if (await Read(SessionKey(sessionId), cancellationToken) is { } stored) + { + await Write(SessionKey(sessionId), stored with { Scheme = session.Scheme, RefreshToken = session.RefreshToken }, SessionEntryOptions(stored.ExpiresAt), cancellationToken); + } + } + finally + { + mutationLock.Release(); + } + } + + /// + public async Task Remove(string sessionId, CancellationToken cancellationToken) + { + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try + { + var stored = await Read(SessionKey(sessionId), cancellationToken); + foreach (var audience in stored?.Audiences ?? []) + { + await cache.RemoveAsync(AccessTokenKey(sessionId, audience), cancellationToken); + await cache.RemoveAsync(RefreshRejectionKey(sessionId, audience), cancellationToken); + } + + await cache.RemoveAsync(SessionKey(sessionId), cancellationToken); + } + finally + { + mutationLock.Release(); + } + } + + /// + public async Task GetAccessToken(string sessionId, string audience, CancellationToken cancellationToken) => + await Get(sessionId, cancellationToken) is not null + ? await Read(AccessTokenKey(sessionId, audience), cancellationToken) + : null; + + /// + public Task SetAccessToken(string sessionId, string audience, CachedUserAccessToken token, DateTimeOffset renewAt, CancellationToken cancellationToken) => + SetAudienceEntry(sessionId, audience, AccessTokenKey(sessionId, audience), token, renewAt, cancellationToken); + + /// + public async Task IsRefreshRejected(string sessionId, string audience, CancellationToken cancellationToken) => + await Get(sessionId, cancellationToken) is not null + && await Read(RefreshRejectionKey(sessionId, audience), cancellationToken) is { } rejection + && rejection.RetryAt > timeProvider.GetUtcNow(); + + /// + public Task SetRefreshRejected(string sessionId, string audience, DateTimeOffset retryAt, CancellationToken cancellationToken) => + SetAudienceEntry(sessionId, audience, RefreshRejectionKey(sessionId, audience), new RefreshRejection(retryAt), retryAt, cancellationToken); + + static string SessionKey(string sessionId) => $"{KeyPrefix}{Hash(sessionId)}"; + + static string AccessTokenKey(string sessionId, string audience) => $"{KeyPrefix}{Hash(sessionId)}:{audience}"; + + static string RefreshRejectionKey(string sessionId, string audience) => $"{AccessTokenKey(sessionId, audience)}:rejected"; + + /// + /// Derives the cache key from the identifier rather than using it, so a cache that can be listed does not hand + /// out the value the cookie proves possession with. + /// + /// The identifier. + /// The derived key. + static string Hash(string value) => WebEncoders.Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + async Task SetAudienceEntry(string sessionId, string audience, string key, T value, DateTimeOffset expiresAt, CancellationToken cancellationToken) + { + var mutationLock = MutationLock(sessionId); + await mutationLock.WaitAsync(cancellationToken); + try + { + var stored = await Read(SessionKey(sessionId), cancellationToken); + if (stored is null) + { + // The session ended while the token was being obtained; keep nothing for it. + return; + } + + if (!stored.Audiences.Contains(audience, StringComparer.Ordinal)) + { + await Write(SessionKey(sessionId), stored with { Audiences = [.. stored.Audiences, audience] }, SessionEntryOptions(stored.ExpiresAt), cancellationToken); + } + + var expiry = stored.ExpiresAt is { } sessionExpiry && sessionExpiry < expiresAt ? sessionExpiry : expiresAt; + await Write( + key, + value, + new DistributedCacheEntryOptions { AbsoluteExpiration = expiry }, + cancellationToken); + } + finally + { + mutationLock.Release(); + } + } + + SemaphoreSlim MutationLock(string sessionId) => _mutationLocks[(uint)StringComparer.Ordinal.GetHashCode(sessionId) % (uint)_mutationLocks.Length]; + + DistributedCacheEntryOptions SessionEntryOptions(DateTimeOffset? expiresAt = null) + { + if (expiresAt is not null) + { + return new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt }; + } + + var current = session.CurrentValue.Session; + var lifetime = current.Lifetime > TimeSpan.Zero ? current.Lifetime : C.Session.DefaultLifetime; + return current.SlidingExpiration + ? new DistributedCacheEntryOptions { SlidingExpiration = lifetime } + : new DistributedCacheEntryOptions { AbsoluteExpiration = timeProvider.GetUtcNow().Add(lifetime) }; + } + + async Task Write(string key, T value, DistributedCacheEntryOptions options, CancellationToken cancellationToken) => + await cache.SetAsync(key, _protector.Protect(JsonSerializer.SerializeToUtf8Bytes(value)), options, cancellationToken); + + async Task Read(string key, CancellationToken cancellationToken) + where T : class + { + var protectedValue = await cache.GetAsync(key, cancellationToken); + if (protectedValue is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(_protector.Unprotect(protectedValue)); + } + catch (CryptographicException) + { + // Written under a key ring this instance does not have; treat it as absent. + return null; + } + } + + sealed record StoredSession(string Scheme, string RefreshToken, string[] Audiences, DateTimeOffset? ExpiresAt); + + sealed record RefreshRejection(DateTimeOffset RetryAt); +} diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index 53e5c493..a2eba1ff 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Security.Claims; +using Cratis.AuthProxy.AccessTokens; using Cratis.AuthProxy.Invites; using Cratis.AuthProxy.Links; using Cratis.AuthProxy.SignIns; @@ -32,6 +33,12 @@ public static class AuthenticationServiceCollectionExtensions /// public const string AuthenticationSchemeStateKey = "Cratis.AuthProxy.AuthenticationScheme"; + /// + /// The key recording which authentication scheme the default scheme selected for + /// the request: the session cookie, a client-credentials token, or a JWT bearer token. + /// + public const string SelectedSchemeItemKey = "Cratis.AuthProxy.SelectedAuthenticationScheme"; + const string ValidatedIssuerStateKey = "Cratis.AuthProxy.ValidatedIssuer"; /// @@ -101,6 +108,13 @@ public static WebApplicationBuilder AddIngressAuthentication(this WebApplication } static string ResolveAuthenticationScheme(HttpContext context, bool hasJwtBearer) + { + var scheme = SelectAuthenticationScheme(context, hasJwtBearer); + context.Items[SelectedSchemeItemKey] = scheme; + return scheme; + } + + static string SelectAuthenticationScheme(HttpContext context, bool hasJwtBearer) { var authorization = context.Request.Headers.Authorization.ToString(); if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) @@ -143,11 +157,28 @@ static void ConfigureCookieOptions(CookieAuthenticationOptions options, C.Sessio var existingValidatePrincipal = options.Events.OnValidatePrincipal; options.Events.OnValidatePrincipal = async context => { + UserTokenSessions.Remember(context); await existingValidatePrincipal(context); if (context.Principal is not null) { await ValidateCanonicalSession(context); } + + await UserTokenSessions.RenewRetention(context); + }; + + var existingSigningIn = options.Events.OnSigningIn; + options.Events.OnSigningIn = async context => + { + await existingSigningIn(context); + await UserTokenSessions.Complete(context); + }; + + var existingSigningOut = options.Events.OnSigningOut; + options.Events.OnSigningOut = async context => + { + await UserTokenSessions.Forget(context); + await existingSigningOut(context); }; // Redirect unauthenticated users to the provider selection page (multiple providers) @@ -287,6 +318,7 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList capturedProvider.UsesClientAssertion ? OidcClientAuthentication.Apply(context.HttpContext, scheme, capturedProvider, context.Options, context.TokenEndpointRequest!) : Task.CompletedTask, diff --git a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs index 738df797..2c36fed1 100644 --- a/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs +++ b/Source/AuthProxy/Authentication/ClientCredentialsServiceResolver.cs @@ -3,6 +3,7 @@ using Cratis.AuthProxy.ReverseProxy; using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Model; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authentication; @@ -87,6 +88,14 @@ public bool TryResolveForRequest(HttpRequest request, out ConfiguredClientCreden var routed = ServiceRoutes.Resolve(request, config.CurrentValue); if (routed is null) { + if (request.HttpContext.GetEndpoint()?.Metadata.GetMetadata() is not null) + { + // A selected proxy endpoint is authoritative, even if its service is no longer configured. + // Never authenticate it as a caller-selected service or the sole client-credentials candidate. + service = default!; + return false; + } + return TryResolveCandidate(request, out service); } diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 24ce54fb..73fe8b20 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -219,6 +219,17 @@ public class Service /// public bool ParticipatesInIdentityResolution => Backend is not null && (ResolveIdentityDetails ?? true); + /// + /// Gets or sets the access token AuthProxy obtains for the signed-in user and forwards to this service's + /// backend as Authorization: Bearer. When absent, no user token is forwarded. + /// + /// + /// Only requests authenticated by the AuthProxy session get a token, and only on the routes to + /// . When no token can be obtained, the request is refused with + /// 401 rather than forwarded without one. + /// + public ServiceAccessToken? AccessToken { get; set; } + /// /// Gets or sets the back-channel client-credentials configuration for this service. /// When configured, AuthProxy can verify client credentials against the service and mint scoped bearer tokens. diff --git a/Source/AuthProxy/Configuration/ServiceAccessToken.cs b/Source/AuthProxy/Configuration/ServiceAccessToken.cs new file mode 100644 index 00000000..8d99475d --- /dev/null +++ b/Source/AuthProxy/Configuration/ServiceAccessToken.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents the access token AuthProxy obtains for the signed-in user and forwards to a service's backend. +/// +/// +/// AuthProxy acts as a backend for frontend: it redeems the refresh token it holds server-side for the user's +/// session at the identity provider's token endpoint, asking for (and +/// when set), and forwards the resulting access token as Authorization: Bearer. The refresh token never +/// leaves AuthProxy, and the ID token is never forwarded. +/// +public class ServiceAccessToken +{ + /// + /// Gets or sets the scopes to request for the backend's audience, for example + /// api://reporting/access_as_user for Microsoft Entra ID. + /// + public IList Scopes { get; set; } = []; + + /// + /// Gets or sets an optional resource indicator (RFC 8707) to request the token for, for identity providers that + /// select the audience with the resource parameter rather than scopes. + /// + public string Resource { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the OIDC provider the token must come from. When empty, the token comes from the + /// provider the user signed in with. When set, a user signed in with another provider gets no token, and the + /// request is refused. + /// + public string Provider { get; set; } = string.Empty; +} diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs index 42b56bdc..fff2a8ea 100644 --- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs +++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Cryptography; +using System.Text; using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; using C = Cratis.AuthProxy.Configuration; @@ -40,6 +42,36 @@ public class MicroserviceReverseProxyConfigProvider : IProxyConfigProvider, IDis /// internal const string AnonymousAuthorizationPolicy = "anonymous"; + /// + /// The cluster metadata key naming the service a cluster belongs to. + /// + internal const string ServiceMetadataKey = "Cratis.AuthProxy.Service"; + + /// + /// The cluster metadata key naming which endpoint of the service a cluster is. + /// + internal const string EndpointMetadataKey = "Cratis.AuthProxy.Endpoint"; + + /// + /// The cluster metadata key holding the access token policy selected with its destination. + /// + internal const string AccessTokenMetadataKey = "Cratis.AuthProxy.AccessToken"; + + /// + /// The cluster metadata key binding the access token policy to its versioned destination. + /// + internal const string DestinationMetadataKey = "Cratis.AuthProxy.Destination"; + + /// + /// The value of a service's backend cluster. + /// + internal const string BackendEndpoint = "Backend"; + + /// + /// The value of a service's frontend cluster. + /// + internal const string FrontendEndpoint = "Frontend"; + /// /// The path prefix served by a service's backend rather than its frontend. /// @@ -75,9 +107,10 @@ public MicroserviceReverseProxyConfigProvider( ILogger logger) { _logger = logger; + var snapshot = config.CurrentValue; _inner = new InMemoryConfigProvider( - BuildRoutes(config.CurrentValue, logger), - BuildClusters(config.CurrentValue)); + BuildRoutes(snapshot, logger), + BuildClusters(snapshot)); _configurationChanged = config.OnChange(Rebuild); } @@ -523,13 +556,20 @@ static List BuildClusters(C.AuthProxy config) if (ms.Backend is not null) { + var destinationId = BackendDestinationId(key, ms); + var metadata = ClusterMetadata(key, BackendEndpoint, ms.AccessToken); + metadata[DestinationMetadataKey] = destinationId; clusters.Add(ClusterFor(config, ms, ms.Backend) with { ClusterId = BackendClusterId(key), + + // Give a changed binding a new destination state so YARP cannot mutate the address while + // a request awaits a token. Metadata lets forwarding reject mixed snapshots during reload. Destinations = new Dictionary { - ["destination1"] = new() { Address = ms.Backend.BaseUrl } + [destinationId] = new() { Address = ms.Backend.BaseUrl } }, + Metadata = metadata, }); } @@ -542,6 +582,7 @@ static List BuildClusters(C.AuthProxy config) { ["destination1"] = new() { Address = ms.Frontend.BaseUrl } }, + Metadata = ClusterMetadata(key, FrontendEndpoint), }); } } @@ -549,6 +590,34 @@ static List BuildClusters(C.AuthProxy config) return clusters; } + static Dictionary ClusterMetadata(string key, string endpoint, C.ServiceAccessToken? accessToken = null) + { + var metadata = new Dictionary + { + [ServiceMetadataKey] = key, + [EndpointMetadataKey] = endpoint, + }; + if (accessToken is not null) + { + metadata[AccessTokenMetadataKey] = JsonSerializer.Serialize(accessToken); + } + + return metadata; + } + + static string BackendDestinationId(string key, C.Service service) + { + if (service.AccessToken is null) + { + return "destination1"; + } + + var binding = JsonSerializer.Serialize(new { Address = service.Backend?.BaseUrl, Policy = service.AccessToken }); + var version = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(binding))); + + return $"{key}-backend-destination-{version}"; + } + /// /// Creates the cluster skeleton for an endpoint, carrying the activity timeout that applies to it. /// diff --git a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs index 5a1478e6..e3bb7b83 100644 --- a/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs +++ b/Source/AuthProxy/ReverseProxy/ReverseProxyExtensions.cs @@ -1,7 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.AccessTokens; using Cratis.AuthProxy.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Configuration; using C = Cratis.AuthProxy.Configuration; @@ -23,6 +25,12 @@ public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder { builder.Services.AddSingleton, ActivityTimeoutConfigurationValidator>(); builder.Services.AddSingleton(); + builder.Services.AddDistributedMemoryCache(); + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, AccessTokenConfigurationValidator>(); + builder.Services.AddHttpClient(UserAccessTokens.HttpClientName, client => client.Timeout = TimeSpan.FromSeconds(10)); builder.Services.AddSingleton( sp => sp.GetRequiredService()); @@ -53,7 +61,15 @@ public static WebApplicationBuilder SetupReverseProxy(this WebApplicationBuilder /// The same for chaining. public static WebApplication UseReverseProxy(this WebApplication app) { - app.MapReverseProxy(); + app.MapReverseProxy(proxy => + { + proxy.UseMiddleware(); + + // The stages MapReverseProxy() runs when it is given no pipeline of its own. + proxy.UseSessionAffinity(); + proxy.UseLoadBalancing(); + proxy.UsePassiveHealthChecks(); + }); return app; } }