diff --git a/Documentation/configuration/forwarded-identity-signing.md b/Documentation/configuration/forwarded-identity-signing.md index 64cab586..7fe8d180 100644 --- a/Documentation/configuration/forwarded-identity-signing.md +++ b/Documentation/configuration/forwarded-identity-signing.md @@ -47,6 +47,48 @@ The service block covers: A service whose frontend and backend verify proofs must provision the matching audience and key at both receivers, or be split into separately configured services. Anonymous requests without principal or on-behalf identity material remain unsigned; tenant or service selectors alone do not create an identity. +## Optional browser-session context + +Set `Cratis:AuthProxy:Services::ForwardSessionContext` to `true` to add browser-session context +only to that service's normal proxied `x-ms-client-principal` envelope. It defaults to `false`: + +```json +{ + "Cratis": { + "AuthProxy": { + "Services": { + "orders": { + "ForwardSessionContext": true + } + } + } + } +} +``` + +- `urn:cratis:session:started` is the original completed sign-in time in Unix seconds. AuthProxy records + it in protected cookie-ticket properties; sliding cookie renewal and provider-token refresh do not + move it. It is not `auth_time`, an identity-provider authentication or reauthentication assertion. +- `urn:cratis:session:sign-in-provider` is AuthProxy's recorded scheme name, for example `microsoft` + or `github`. It names the sign-in handler, not an issuer, subject, actor, or tenant. + +Both values come from AuthProxy-authored protected session state, never upstream claims, invite +metadata, or request headers. Legacy cookies without a recorded start omit `started`; AuthProxy does +not fabricate a time from cookie issuance, expiry, current time, or `auth_time`. A missing recorded +provider is likewise omitted. No new top-level envelope fields or separate headers are introduced. +With the setting off, the existing envelope is unchanged except that forged reserved claims are removed. + +The route-selected service controls the opt-in, not caller-supplied service headers or query parameters. +It does not add context to `/identity`, identity-details back channels, or callbacks, nor does it +synthesize browser context for bearer, client-credential, or capability credentials. Token-supplied +copies of these two reserved claims are removed from bearer envelopes. + +When forwarded-identity signing is enabled, the existing proof covers the complete principal envelope, +including these claims. A backend must verify the proof before relying on session context; unsigned +headers alone are not trusted evidence. Enabling this option does not enable signing, upgrade v1 to v2, +or change v2's supported credential kinds or schema. A verified scheme still does not replace the +canonical issuer/subject or actor attribution. + ## Bearer resource and scheme A validated access-token principal carries exactly one `urn:cratis:bearer:resource` claim: the configured diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/given/a_session_context.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/given/a_session_context.cs new file mode 100644 index 00000000..77eb60fc --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/given/a_session_context.cs @@ -0,0 +1,41 @@ +// 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.Nodes; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding.given; + +public class a_session_context +{ + protected static JsonArray Claims(ForwardedRequest request) => JsonNode.Parse(Convert.FromBase64String(request.Value(Headers.Principal)))!["claims"]!.AsArray(); + protected static string Value(ForwardedRequest request, string type) => Claims(request).Single(claim => claim!["typ"]!.GetValue() == type)!["val"]!.GetValue(); + protected static ForwardedIdentityObservation Observation(ForwardedRequest request) => new(request.Method, request.Path + request.QueryString, request.Headers.ToDictionary(header => header.Key, header => new[] { header.Value }, StringComparer.OrdinalIgnoreCase), false); + protected static bool Verify(ForwardedIdentityObservation request) => ForwardedIdentityVerifier.Verify(request, SessionContextHarness.Audience, SessionContextHarness.KeyId, SessionContextHarness.Key); + + protected static async Task RejectSpoof(string type, string suffix) + { + await using var harness = new SessionContextHarness(); + var cookie = await harness.SignIn(hostileClaim: type); + using var client = harness.CreateBearerClient(); + using var request = SessionContextHarness.Request(cookie); + request.Headers.Add(Headers.Principal, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($$"""{"claims":[{"typ":"{{type}}","val":"caller-forged"}]}"""))); + request.Headers.Add("x-ms-client-principal-" + suffix, "caller-forged"); + request.Headers.Add(HeaderAuthenticationHandler.ClaimsHeader, type + "=caller-forged"); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var observed = harness.Observed(); + Assert.NotEqual("caller-forged", Value(observed, type)); + Assert.DoesNotContain("caller-forged", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(observed.Value(Headers.Principal))), StringComparison.Ordinal); + Assert.False(observed.Has("x-ms-client-principal-" + suffix)); + Assert.True(Verify(Observation(observed))); + } + + protected static async Task Forward(SessionContextHarness harness, string cookie) + { + using var client = harness.CreateBearerClient(); + using var request = SessionContextHarness.Request(cookie); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return harness.Observed(); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_session_started_is_spoofed.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_session_started_is_spoofed.cs new file mode 100644 index 00000000..7dc26e9d --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_session_started_is_spoofed.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. + +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_an_inbound_session_started_is_spoofed : given.a_session_context +{ + [Fact] public Task should_discard_the_provider_claim_header_and_forged_envelope() => RejectSpoof(SessionClaims.Started, "started"); +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_sign_in_provider_is_spoofed.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_sign_in_provider_is_spoofed.cs new file mode 100644 index 00000000..ac67db77 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_an_inbound_sign_in_provider_is_spoofed.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. + +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_an_inbound_sign_in_provider_is_spoofed : given.a_session_context +{ + [Fact] public Task should_discard_the_provider_claim_header_and_forged_envelope() => RejectSpoof(SessionClaims.SignInProvider, "sign-in-provider"); +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_off.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_off.cs new file mode 100644 index 00000000..11aa1e10 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_off.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. + +using System.Text; +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_session_context_is_off : given.a_session_context +{ + [Fact] + public async Task should_preserve_the_previous_envelope_bytes() + { + await using var harness = new SessionContextHarness(false); + var observed = await Forward(harness, await harness.SignIn()); + const string expected = "{\"identityProvider\":\"AuthenticationTypes.Federation\",\"userId\":\"session-user\",\"userDetails\":\"user@example.test\",\"userRoles\":[\"anonymous\",\"authenticated\"],\"claims\":[{\"typ\":\"sub\",\"val\":\"session-user\"},{\"typ\":\"email\",\"val\":\"user@example.test\"}]}"; + Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes(expected)), observed.Value(Headers.Principal)); + Assert.DoesNotContain(Claims(observed), claim => SessionClaims.IsForwardedSessionContext(claim!["typ"]!.GetValue())); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_on_for_a_signed_service.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_on_for_a_signed_service.cs new file mode 100644 index 00000000..f399e9cc --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_session_context_is_on_for_a_signed_service.cs @@ -0,0 +1,33 @@ +// 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; +using System.Text.Json.Nodes; +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_session_context_is_on_for_a_signed_service : given.a_session_context +{ + [Theory] + [InlineData(true, "provider-one")] + [InlineData(false, "github")] + public async Task should_forward_only_protected_context_and_cover_both_claims_with_the_signature(bool oidc, string scheme) + { + await using var harness = new SessionContextHarness(); + var cookie = await harness.SignIn(oidc); + var observed = await Forward(harness, cookie); + Assert.Equal(harness.Clock.GetUtcNow().ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture), Value(observed, SessionClaims.Started)); + Assert.Equal(scheme, Value(observed, SessionClaims.SignInProvider)); + Assert.DoesNotContain(Claims(observed), claim => claim!["typ"]!.GetValue() == "auth_time"); + Assert.True(Verify(Observation(observed))); + foreach (var type in new[] { SessionClaims.Started, SessionClaims.SignInProvider }) + { + var principal = JsonNode.Parse(Convert.FromBase64String(observed.Value(Headers.Principal)))!; + principal["claims"]!.AsArray().Single(claim => claim!["typ"]!.GetValue() == type)!["val"] = "tampered"; + var headers = Observation(observed).Headers.ToDictionary(header => header.Key, header => header.Value, StringComparer.OrdinalIgnoreCase); + headers[Headers.Principal] = [Convert.ToBase64String(Encoding.UTF8.GetBytes(principal.ToJsonString()))]; + Assert.False(Verify(Observation(observed) with { Headers = headers })); + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_cookie_is_renewed.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_cookie_is_renewed.cs new file mode 100644 index 00000000..5135b7a6 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_cookie_is_renewed.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. + +using Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_the_session_cookie_is_renewed : given.a_session_context +{ + [Fact] + public async Task should_keep_the_original_start_after_sliding_renewal() + { + await using var harness = new SessionContextHarness(); + var cookie = await harness.SignIn(); + var before = await Forward(harness, cookie); + harness.Clock.Advance(TimeSpan.FromMinutes(31)); + using var client = harness.CreateBearerClient(); + using var request = SessionContextHarness.Request(cookie); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var renewed)); + var renewedCookie = string.Join("; ", renewed.Select(value => value.Split(';', 2)[0])); + Assert.NotEqual(cookie, renewedCookie); + var after = await Forward(harness, renewedCookie); + Assert.Equal(Value(before, SessionClaims.Started), Value(after, SessionClaims.Started)); + Assert.Equal(Value(before, SessionClaims.SignInProvider), Value(after, SessionClaims.SignInProvider)); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_tokens_are_refreshed.cs b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_tokens_are_refreshed.cs new file mode 100644 index 00000000..da0d1141 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_SessionContextForwarding/when_the_session_tokens_are_refreshed.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. + +using Cratis.AuthProxy.AccessTokens; +using Cratis.AuthProxy.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.for_SessionContextForwarding; + +public class when_the_session_tokens_are_refreshed : given.a_session_context +{ + [Fact] + public async Task should_keep_the_original_context_after_refreshing_the_provider_token() + { + await using var harness = new SessionContextHarness(); + using var client = harness.CreateBearerClient(); + var session = await harness.Services.GetRequiredService().Create(new("provider-one", "spec-refresh-token"), CancellationToken.None); + var cookie = await harness.SignIn(tokenSession: session); + var before = await Forward(harness, cookie); + harness.Clock.Advance(TimeSpan.FromMinutes(1)); + var token = await harness.Services.GetRequiredService().GetFor(session, new C.ServiceAccessToken { Scopes = ["api://service/read"] }, CancellationToken.None); + Assert.Equal("refreshed-token", token.Token); + Assert.Equal(1, harness.Refreshes); + var after = await Forward(harness, cookie); + Assert.Equal(Value(before, SessionClaims.Started), Value(after, SessionClaims.Started)); + Assert.Equal(Value(before, SessionClaims.SignInProvider), Value(after, SessionClaims.SignInProvider)); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/SessionContextHarness.cs b/Source/AuthProxy.Security.Specs/given/SessionContextHarness.cs new file mode 100644 index 00000000..e1b05eed --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/SessionContextHarness.cs @@ -0,0 +1,127 @@ +// 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.Claims; +using Cratis.AuthProxy.AccessTokens; +using Cratis.AuthProxy.ForwardedIdentity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.Security.given; + +/// A signed service using real cookie tickets and the completed provider sign-in events. +/// Whether this service opts in to session-context forwarding. +public class SessionContextHarness(bool enabled = true) : ForwardedIdentityHarness +{ + public const string Path = "/signed-a"; + public new const string Audience = "signed-backend"; + public const string KeyId = "active-1"; + public static readonly byte[] Key = Enumerable.Repeat((byte)42, 32).ToArray(); + public int Refreshes { get; private set; } + public FakeTimeProvider Clock { get; } = new(DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds())); + protected override bool UsesHeaderAuthentication => false; + + public async Task SignIn(bool oidc = true, string? hostileClaim = null, string? tokenSession = null) + { + var scheme = oidc ? "provider-one" : "github"; + var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "session-user"), new Claim("email", "user@example.test")], "AuthenticationTypes.Federation")); + if (hostileClaim is not null) ((ClaimsIdentity)principal.Identity!).AddClaim(new Claim(hostileClaim, "caller-forged")); + var properties = new AuthenticationProperties { IssuedUtc = Clock.GetUtcNow(), ExpiresUtc = Clock.GetUtcNow().AddHours(1) }; + var context = new DefaultHttpContext { RequestServices = Services }; + context.Request.Scheme = "https"; + RemoteAuthenticationOptions providerOptions = oidc + ? Services.GetRequiredService>().Get(scheme) + : Services.GetRequiredService>().Get(scheme); + var ticket = new TicketReceivedContext(context, new AuthenticationScheme(scheme, scheme, oidc ? typeof(OpenIdConnectHandler) : typeof(OAuthHandler)), providerOptions, new AuthenticationTicket(principal, properties, scheme)); + if (providerOptions is OpenIdConnectOptions oidcOptions) await oidcOptions.Events.OnTicketReceived(ticket); + else await ((OAuthOptions)providerOptions).Events.OnTicketReceived(ticket); + if (tokenSession is not null) properties.Items[UserTokenSessions.PropertiesKey] = tokenSession; + var options = Services.GetRequiredService>().Get("Cookies"); + options.CookieManager.AppendResponseCookie(context, options.Cookie.Name!, options.TicketDataFormat.Protect(new AuthenticationTicket(ticket.Principal!, properties, "Cookies")), options.Cookie.Build(context)); + return string.Join("; ", context.Response.Headers.SetCookie.Select(cookie => cookie!.Split(';', 2)[0])); + } + + public static HttpRequestMessage Request(string cookie) + { + var request = new HttpRequestMessage(HttpMethod.Get, Path); + request.Headers.Host = SignedHost; + request.Headers.Add("Cookie", cookie); + return request; + } + + protected override void AddSettings(IDictionary settings) + { + base.AddSettings(settings); + const string service = $"{C.AuthProxy.SectionKey}:Services:app"; + settings[$"{service}:ResolveIdentityDetails"] = "false"; + settings[$"{service}:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort); + if (enabled) settings[$"{service}:ForwardSessionContext"] = "true"; + settings[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientSecret"] = "spec-client-secret"; + settings[$"{C.Session.SectionKey}:Lifetime"] = "01:00:00"; + settings[$"{C.Session.SectionKey}:SlidingExpiration"] = "true"; + settings[$"{C.Authentication.SectionKey}:OAuthProviders:0:Name"] = "GitHub"; + settings[$"{C.Authentication.SectionKey}:OAuthProviders:0:AuthorizationEndpoint"] = "https://github.example.test/authorize"; + settings[$"{C.Authentication.SectionKey}:OAuthProviders:0:TokenEndpoint"] = "https://github.example.test/token"; + settings[$"{C.Authentication.SectionKey}:OAuthProviders:0:UserInformationEndpoint"] = "https://github.example.test/user"; + settings[$"{C.Authentication.SectionKey}:OAuthProviders:0:ClientId"] = "github-client"; + } + + public ForwardedRequest Observed() + { + var observation = ApplicationOrigin.Received.Last(request => request.RawTarget == "/base" + Path); + return new ForwardedRequest(observation.Method, observation.RawTarget, string.Empty, observation.Headers.ToDictionary(header => header.Key, header => header.Value.Single(), StringComparer.OrdinalIgnoreCase)); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Provider registration reads configuration while Program builds the authentication handlers. + builder + .UseSetting($"{C.Authentication.SectionKey}:OidcProviders:0:Name", "Provider One") + .UseSetting($"{C.Authentication.SectionKey}:OidcProviders:0:Authority", "https://login.example.test/one") + .UseSetting($"{C.Authentication.SectionKey}:OidcProviders:0:ClientId", "client-one") + .UseSetting($"{C.Authentication.SectionKey}:OidcProviders:0:ClientSecret", "spec-client-secret") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:Name", "GitHub") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:ClientId", "github-client") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:ClientSecret", "spec-client-secret") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:AuthorizationEndpoint", "https://github.example.test/authorize") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:TokenEndpoint", "https://github.example.test/token") + .UseSetting($"{C.Authentication.SectionKey}:OAuthProviders:0:UserInformationEndpoint", "https://github.example.test/user") + .UseSetting($"{C.Session.SectionKey}:Lifetime", "01:00:00") + .UseSetting($"{C.Session.SectionKey}:SlidingExpiration", "true"); + base.ConfigureWebHost(builder); + } + + protected override void ConfigureHost(IWebHostBuilder builder) + { + base.ConfigureHost(builder); + builder.ConfigureTestServices(services => + { + services.AddSingleton(Clock); + + // Advancing session time must not expire the proof against the independent origin's wall clock. + services.AddSingleton(provider => new ForwardedIdentitySigner(provider.GetRequiredService>(), TimeProvider.System, provider.GetRequiredService>())); + services.PostConfigure("Cookies", options => options.TimeProvider = Clock); + services.PostConfigure("provider-one", options => options.Configuration = new OpenIdConnectConfiguration { TokenEndpoint = "https://login.example.test/token" }); + services.AddHttpClient(UserAccessTokens.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => new RefreshEndpoint(this)); + }); + } + + sealed class RefreshEndpoint(SessionContextHarness harness) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + harness.Refreshes++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"access_token\":\"refreshed-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}", System.Text.Encoding.UTF8, "application/json") }); + } + } +} diff --git a/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_completed_sign_ins_record_a_session_start.cs b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_completed_sign_ins_record_a_session_start.cs new file mode 100644 index 00000000..0f093905 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_AuthenticationServiceCollectionExtensions/when_completed_sign_ins_record_a_session_start.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. + +using Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions.given; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions; + +public class when_completed_sign_ins_record_a_session_start : configured_provider_sessions +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task should_replace_incoming_metadata_with_the_sign_in_time(bool oidc) + { + await using var services = Services(null, null); + var properties = new AuthenticationProperties(new Dictionary { [SessionStart.PropertyKey] = "1" }); + var ticket = Ticket(services, oidc, properties); + ((ClaimsIdentity)ticket.Principal!.Identity!).AddClaim(new Claim(SessionClaims.Started, "2")); + await Receive(ticket); + SessionStart.TryRead(properties, out var started).ShouldBeTrue(); + started.ShouldEqual(_clock.GetUtcNow()); + ticket.Principal.Claims.Any(_ => _.Type == SessionClaims.Started).ShouldBeFalse(); + } +} diff --git a/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_reading_an_invalid_start.cs b/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_reading_an_invalid_start.cs new file mode 100644 index 00000000..cecf96a1 --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_reading_an_invalid_start.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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Authentication.for_SessionStart; + +public class when_reading_an_invalid_start : Specification +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("-1")] + [InlineData("253402300800")] + [InlineData("not-a-time")] + public void should_not_read_a_start(string? value) => SessionStart.TryRead(new AuthenticationProperties(new Dictionary { [SessionStart.PropertyKey] = value }), out _).ShouldBeFalse(); + [Fact] void should_not_read_missing_properties() => SessionStart.TryRead(null, out _).ShouldBeFalse(); + [Fact] void should_not_read_a_legacy_session() => SessionStart.TryRead(new AuthenticationProperties(), out _).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_stamping_at_sign_in.cs b/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_stamping_at_sign_in.cs new file mode 100644 index 00000000..c574ee3b --- /dev/null +++ b/Source/AuthProxy.Specs/Authentication/for_SessionStart/when_stamping_at_sign_in.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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Authentication.for_SessionStart; + +public class when_stamping_at_sign_in : Specification +{ + AuthenticationProperties _properties; + DateTimeOffset _time; + bool _read; + + void Establish() => _properties = new AuthenticationProperties(new Dictionary { [SessionStart.PropertyKey] = "untrusted" }); + void Because() + { + SessionStart.Stamp(_properties, DateTimeOffset.FromUnixTimeSeconds(1700000000)); + _read = SessionStart.TryRead(_properties, out _time); + } + + [Fact] void should_overwrite_the_incoming_property() => _properties.Items[SessionStart.PropertyKey].ShouldEqual("1700000000"); + [Fact] void should_read_the_protected_start() => _read.ShouldBeTrue(); + [Fact] void should_round_trip_the_sign_in_time() => _time.ShouldEqual(DateTimeOffset.FromUnixTimeSeconds(1700000000)); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerForwardedIdentity/when_a_bearer_token_contains_session_context.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerForwardedIdentity/when_a_bearer_token_contains_session_context.cs new file mode 100644 index 00000000..32e10faf --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerForwardedIdentity/when_a_bearer_token_contains_session_context.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 Cratis.AuthProxy.Authentication; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerForwardedIdentity; + +public class when_a_bearer_token_contains_session_context : Specification +{ + [Theory] + [InlineData(SessionClaims.Started)] + [InlineData(SessionClaims.SignInProvider)] + [InlineData("URN:CRATIS:SESSION:STARTED")] + public void should_drop_the_reserved_claim_without_dropping_other_claims(string reserved) + { + var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "client"), new Claim(reserved, "forged"), new Claim("scope", "read"), new Claim("auth_time", "1700000000")], "Bearer")); + var config = new C.AuthProxy { Services = new Dictionary { ["app"] = new() { Backend = new() { BaseUrl = "https://backend.example.test" }, BearerRoutes = [new() { PathPrefix = "/api", Issuers = [new() { Issuer = "https://issuer.example.test" }], Audiences = ["backend"] }] } } }; + var principal = BearerForwardedIdentity.From(BearerTokenValidation.Success(user, "tenant"), BearerRouteTable.All(config).Single()).Principal; + principal.Claims.Any(_ => SessionClaims.IsForwardedSessionContext(_.Type)).ShouldBeFalse(); + principal.Claims.Any(_ => _.Type == "scope").ShouldBeTrue(); + principal.Claims.Any(_ => _.Type == "auth_time").ShouldBeTrue(); + } +} diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/given/a_recorded_session.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/given/a_recorded_session.cs new file mode 100644 index 00000000..49f1e3dd --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/given/a_recorded_session.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. + +using Cratis.AuthProxy.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions.given; + +public class a_recorded_session : Specification +{ + protected DefaultHttpContext _context; + protected AuthenticationProperties _properties; + protected ClientPrincipal _principal; + + void Establish() + { + var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "user-42")], "AuthenticationTypes.Federation")); + _properties = new AuthenticationProperties(); + SessionProvider.Stamp(user, _properties, "microsoft"); + SessionStart.Stamp(_properties, DateTimeOffset.FromUnixTimeSeconds(1700000000)); + _context = new DefaultHttpContext { RequestServices = new ServiceCollection().BuildServiceProvider(), User = user }; + _context.Features.Set(new ResultFeature(AuthenticateResult.Success(new AuthenticationTicket(user, _properties, CookieAuthenticationDefaults.AuthenticationScheme)))); + _principal = _context.BuildClientPrincipal()!; + } + + protected sealed class ResultFeature(AuthenticateResult result) : IAuthenticateResultFeature + { + public AuthenticateResult? AuthenticateResult { get; set; } = result; + } +} diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_forwarded.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_forwarded.cs new file mode 100644 index 00000000..7483972c --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_forwarded.cs @@ -0,0 +1,16 @@ +// 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; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions; + +public class when_session_context_is_forwarded : given.a_recorded_session +{ + ClientPrincipal _result; + void Because() => _result = _principal.WithSessionContext(_context); + [Fact] void should_forward_the_recorded_start() => _result.Claims.Single(_ => _.Type == SessionClaims.Started).Value.ShouldEqual("1700000000"); + [Fact] void should_forward_the_recorded_scheme() => _result.Claims.Single(_ => _.Type == SessionClaims.SignInProvider).Value.ShouldEqual("microsoft"); + [Fact] void should_preserve_the_account_provider() => _result.AccountProvider.ShouldEqual(_principal.AccountProvider); + [Fact] void should_not_modify_the_original_envelope() => _principal.Claims.Any(_ => SessionClaims.IsReserved(_.Type)).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_unavailable.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_unavailable.cs new file mode 100644 index 00000000..78e357ca --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_session_context_is_unavailable.cs @@ -0,0 +1,41 @@ +// 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; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions; + +public class when_session_context_is_unavailable : given.a_recorded_session +{ + [Theory] + [InlineData(null)] + [InlineData("invalid")] + [InlineData("-1")] + public void should_not_invent_a_start(string? start) + { + _properties.Items[SessionStart.PropertyKey] = start; + var result = _principal.WithSessionContext(_context); + result.Claims.Any(_ => _.Type == SessionClaims.Started).ShouldBeFalse(); + result.Claims.Single(_ => _.Type == SessionClaims.SignInProvider).Value.ShouldEqual("microsoft"); + } + + [Theory] + [InlineData("Bearer")] + [InlineData("ClientCredentials")] + [InlineData("Capability")] + public void should_not_forward_context_for_a_non_cookie_credential(string scheme) + { + _context.Features.Set(new ResultFeature(AuthenticateResult.Success(new AuthenticationTicket(_context.User, _properties, scheme)))); + _principal.WithSessionContext(_context).ShouldBeSame(_principal); + } + + [Fact] + public void should_not_invent_a_missing_provider() + { + _properties.Items.Remove(SessionProvider.MarkerStateKey); + var result = _principal.WithSessionContext(_context); + result.Claims.Any(_ => _.Type == SessionClaims.SignInProvider).ShouldBeFalse(); + result.Claims.Single(_ => _.Type == SessionClaims.Started).Value.ShouldEqual("1700000000"); + } +} diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_upstream_claims_impersonate_session_context.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_upstream_claims_impersonate_session_context.cs new file mode 100644 index 00000000..03e7416b --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_upstream_claims_impersonate_session_context.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.Authentication; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions; + +public class when_upstream_claims_impersonate_session_context : given.a_recorded_session +{ + [Theory] + [InlineData(SessionClaims.Started)] + [InlineData(SessionClaims.SignInProvider)] + [InlineData("URN:CRATIS:SESSION:STARTED")] + public void should_filter_upstream_claims_when_forwarding_is_off(string type) + { + ((ClaimsIdentity)_context.User.Identity!).AddClaim(new Claim(type, "forged")); + _context.BuildClientPrincipal()!.Claims.Any(_ => SessionClaims.IsForwardedSessionContext(_.Type)).ShouldBeFalse(); + } +} diff --git a/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_the_route_selects_session_context_forwarding.cs b/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_the_route_selects_session_context_forwarding.cs new file mode 100644 index 00000000..530b35b9 --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_the_route_selects_session_context_forwarding.cs @@ -0,0 +1,39 @@ +// 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.Extensions.DependencyInjection; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Model; +using Yarp.ReverseProxy.Transforms; + +namespace Cratis.AuthProxy.Identity.for_InjectIdentityHeadersTransform; + +public class when_the_route_selects_session_context_forwarding : for_ClientPrincipalExtensions.given.a_recorded_session +{ + [Theory] + [InlineData("enabled", true)] + [InlineData("disabled", false)] + [InlineData("unknown", false)] + [InlineData(null, false)] + public async Task should_use_only_the_routed_service(string? service, bool expected) + { + var config = new C.AuthProxy { Services = new Dictionary { ["enabled"] = new() { ForwardSessionContext = true }, ["disabled"] = new() } }; + await using var services = new ServiceCollection().Configure(options => options.Services = config.Services).BuildServiceProvider(); + _context.RequestServices = services; + _context.Request.Host = new HostString(expected ? "disabled" : "enabled"); + _context.Request.Headers[Headers.ServiceId] = expected ? "disabled" : "enabled"; + _context.Request.QueryString = new QueryString("?service=enabled"); + if (service is not null) + { + var route = new RouteModel(new RouteConfig { RouteId = "routed", Metadata = new Dictionary { [ServiceSelection.RouteMetadataKey] = service }, Match = new RouteMatch { Path = "/{**catch-all}" } }, new ClusterState("cluster"), HttpTransformer.Default); + _context.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(route), "proxied")); + } + using var request = new HttpRequestMessage(HttpMethod.Get, "https://backend.example.test/request"); + await new InjectIdentityHeadersTransform().ApplyAsync(new RequestTransformContext { HttpContext = _context, ProxyRequest = request }); + ClientPrincipal.TryFromBase64(request.Headers.GetValues(Headers.Principal).Single(), out var principal).ShouldBeTrue(); + principal!.Claims.Any(_ => _.Type == SessionClaims.Started).ShouldEqual(expected); + principal.Claims.Any(_ => _.Type == SessionClaims.SignInProvider).ShouldEqual(expected); + } +} diff --git a/Source/AuthProxy.Specs/Identity/for_InviteTokenClaimsPrincipalEnricher/when_invite_metadata_targets_session_context.cs b/Source/AuthProxy.Specs/Identity/for_InviteTokenClaimsPrincipalEnricher/when_invite_metadata_targets_session_context.cs new file mode 100644 index 00000000..90c3ffb3 --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_InviteTokenClaimsPrincipalEnricher/when_invite_metadata_targets_session_context.cs @@ -0,0 +1,39 @@ +// 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 Cratis.AuthProxy.Invites; + +namespace Cratis.AuthProxy.Identity.for_InviteTokenClaimsPrincipalEnricher; + +public class when_invite_metadata_targets_session_context : Specification +{ + [Theory] + [InlineData(SessionClaims.Started)] + [InlineData(SessionClaims.SignInProvider)] + [InlineData("URN:CRATIS:SESSION:STARTED")] + public void should_ignore_reserved_targets_but_keep_ordinary_metadata(string target) + { + var validator = Substitute.For(); + validator.TryGetClaim("invite-token", "team", out Arg.Any()).Returns(call => + { + call[2] = "blue"; + return true; + }); + var config = new C.AuthProxy + { + Invite = new C.Invite + { + ClaimsToForward = [new C.InviteClaimForwarding { FromClaimType = "team", ToClaimType = target }, new C.InviteClaimForwarding { FromClaimType = "team" }] + } + }; + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(config); + var context = new DefaultHttpContext(); + context.Request.Headers.Cookie = $"{Cookies.InviteToken}=invite-token"; + var principal = new ClientPrincipal { Claims = [] }; + var result = new InviteTokenClaimsPrincipalEnricher(validator, monitor).Enrich(context, principal); + result.Claims.Any(_ => SessionClaims.IsForwardedSessionContext(_.Type)).ShouldBeFalse(); + result.Claims.Single().Value.ShouldEqual("blue"); + } +} diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index b67e8b5e..a6d33a69 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -721,6 +721,7 @@ static async Task CompleteSignIn(TicketReceivedContext context, bool oidc) // The same scheme is also recorded as a claim on the session itself, so an account from a provider without // canonical identity is named by the provider it signed in with. SessionProvider.Stamp(context.Principal!, context.Properties, context.Scheme.Name); + SessionStart.Stamp(context.Properties!, services.GetRequiredService().GetUtcNow()); FreshAuthenticationChallenge.Stamp(context.Principal!, context.Properties!, oidc); BrowserSessionEvidence.Stamp(context.HttpContext, context.Principal!, context.Properties!, oidc ? context.Scheme.Name : null); context.Properties?.Items.TryAdd(AuthenticationSchemeStateKey, context.Scheme.Name); diff --git a/Source/AuthProxy/Authentication/SessionClaims.cs b/Source/AuthProxy/Authentication/SessionClaims.cs index 7617acab..ba630aab 100644 --- a/Source/AuthProxy/Authentication/SessionClaims.cs +++ b/Source/AuthProxy/Authentication/SessionClaims.cs @@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Authentication; /// /// /// AuthProxy removes any claim in this namespace supplied by a provider before it records its own value when a -/// sign-in completes. The claim is never forwarded to applications. +/// sign-in completes. Only explicitly enabled session-context claims are forwarded to applications. /// public static class SessionClaims { @@ -20,6 +20,19 @@ public static class SessionClaims /// The Unix time of the authentication recorded by AuthProxy. public const string AuthenticationTime = "urn:cratis:session:auth_time"; + /// The original session start forwarded in Unix seconds when enabled for a service. + public const string Started = "urn:cratis:session:started"; + + /// The sign-in provider scheme forwarded when enabled for a service. + public const string SignInProvider = "urn:cratis:session:sign-in-provider"; + + /// Determines whether a type is reserved for forwarded session context. + /// The claim type to inspect. + /// Whether the type is one of the two AuthProxy-authored forwarded claims. + public static bool IsForwardedSessionContext(string claimType) => + string.Equals(claimType, Started, StringComparison.OrdinalIgnoreCase) + || string.Equals(claimType, SignInProvider, StringComparison.OrdinalIgnoreCase); + /// /// Determines whether a claim type belongs to the namespace reserved for AuthProxy-authored session metadata. /// diff --git a/Source/AuthProxy/Authentication/SessionStart.cs b/Source/AuthProxy/Authentication/SessionStart.cs new file mode 100644 index 00000000..c8e2a689 --- /dev/null +++ b/Source/AuthProxy/Authentication/SessionStart.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 System.Globalization; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Authentication; + +/// Records the completed sign-in time in protected ticket properties, independent of cookie renewal. +internal static class SessionStart +{ + /// The protected cookie property holding the original session start. + internal const string PropertyKey = ".cratis.ss"; + + /// Replaces an incoming property with AuthProxy's completed sign-in time. + /// The protected session properties. + /// The sign-in time. + internal static void Stamp(AuthenticationProperties properties, DateTimeOffset time) => + properties.Items[PropertyKey] = time.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + + /// Reads a valid session start without inventing one for legacy tickets. + /// The protected session properties. + /// The original sign-in time. + /// Whether the ticket carries a supported nonnegative Unix timestamp. + internal static bool TryRead(AuthenticationProperties? properties, out DateTimeOffset time) + { + time = default; + return properties?.Items.TryGetValue(PropertyKey, out var value) == true + && SessionAuthenticationTime.TryParse(value, out time); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs index 564d24b8..2a79a813 100644 --- a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs +++ b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs @@ -1,6 +1,7 @@ // 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 Cratis.AuthProxy.Identity; namespace Cratis.AuthProxy.BearerRoutes; @@ -50,7 +51,7 @@ public static BearerForwardedIdentity From(BearerTokenValidation validation, Res UserId = subject, UserDetails = details, UserRoles = ["anonymous", "authenticated"], - Claims = [.. user.Claims.Select(_ => new ClientPrincipalClaim { Type = _.Type, Value = _.Value })], + Claims = [.. user.Claims.Where(_ => !SessionClaims.IsForwardedSessionContext(_.Type)).Select(_ => new ClientPrincipalClaim { Type = _.Type, Value = _.Value })], }; if (validation.Resource is { } resource) diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 40b79130..ae450590 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -106,6 +106,12 @@ public class Service /// public TimeSpan? ActivityTimeout { get; set; } + /// + /// Gets or sets whether to add the session start and the sign-in provider to the principal envelope + /// for this service. Off by default. + /// + public bool ForwardSessionContext { get; set; } + /// /// Gets or sets the request paths on this service that are served to unauthenticated callers. /// diff --git a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs index 3287d8a8..b49fb079 100644 --- a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs +++ b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs @@ -175,6 +175,39 @@ public static void SetMicrosoftIdentityHeaders(this HttpRequestMessage requestMe } } + /// Adds AuthProxy's protected browser-session context to a copy of the outbound envelope. + /// The envelope to enrich. + /// The authenticated request. + /// The enriched envelope, or the original for a non-cookie credential. + internal static ClientPrincipal WithSessionContext(this ClientPrincipal principal, HttpContext context) + { + var result = context.Features.Get()?.AuthenticateResult; + if (result?.Ticket?.AuthenticationScheme != CookieAuthenticationDefaults.AuthenticationScheme) + { + return principal; + } + + var claims = principal.Claims.Where(_ => !SessionClaims.IsForwardedSessionContext(_.Type)).ToList(); + if (SessionStart.TryRead(result.Properties, out var started)) + { + claims.Add(new ClientPrincipalClaim { Type = SessionClaims.Started, Value = started.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }); + } + if (SessionProvider.TryRead(context.User, result.Properties, out var provider)) + { + claims.Add(new ClientPrincipalClaim { Type = SessionClaims.SignInProvider, Value = provider }); + } + + return new ClientPrincipal + { + IdentityProvider = principal.IdentityProvider, + UserId = principal.UserId, + UserDetails = principal.UserDetails, + UserRoles = principal.UserRoles, + Claims = claims, + AccountProvider = principal.AccountProvider + }; + } + static CanonicalIdentityResolution? ResolveCanonicalIdentity(HttpContext context, ClaimsPrincipal user, string? authenticationScheme) => context.RequestServices is { } requestServices ? requestServices.GetService()?.Resolve(user, authenticationScheme) diff --git a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs index 17a91abc..4d33c2c8 100644 --- a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs +++ b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.AuthProxy.OnBehalfSessions; +using Microsoft.Extensions.Options; using Yarp.ReverseProxy.Transforms; +using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Identity; @@ -29,10 +31,11 @@ public override ValueTask ApplyAsync(RequestTransformContext context) // the tenancy middleware having run, so nothing a caller sent survives whatever path led here. SpoofableHeaders.Strip(context.ProxyRequest.Headers); + var service = ServiceSelection.FromRoute(httpContext); var principal = AnonymousPaths.PassesCallerAuthorization(httpContext) ? null : httpContext.BuildClientPrincipal(); if (principal is not null) { - context.ProxyRequest.SetMicrosoftIdentityHeaders(principal); + SetPrincipalHeaders(context, principal); } // During an on-behalf session the principal above is the represented user. The actor and the signed assertion @@ -53,7 +56,6 @@ public override ValueTask ApplyAsync(RequestTransformContext context) // Both backend contracts identify the destination, not a caller-supplied selector that may have // lost to an anonymous-path, query, or single-service catch-all route. - var service = ServiceSelection.FromRoute(httpContext); context.ProxyRequest.Headers.Remove(Headers.ServiceId); context.ProxyRequest.Headers.Remove(Headers.LegacyServiceId); if (!string.IsNullOrWhiteSpace(service)) @@ -65,4 +67,21 @@ public override ValueTask ApplyAsync(RequestTransformContext context) return ValueTask.CompletedTask; } + + static void SetPrincipalHeaders(RequestTransformContext context, ClientPrincipal principal) + { + if (ForwardsSessionContext(context.HttpContext)) + { + principal = principal.WithSessionContext(context.HttpContext); + } + context.ProxyRequest.SetMicrosoftIdentityHeaders(principal); + } + + static bool ForwardsSessionContext(HttpContext context) + { + var service = ServiceSelection.FromRoute(context); + var config = context.RequestServices?.GetService>()?.CurrentValue; + return service is not null && config is not null + && config.Services.TryGetValue(service, out var selected) && selected.ForwardSessionContext; + } } diff --git a/Source/AuthProxy/Identity/InviteTokenClaimsPrincipalEnricher.cs b/Source/AuthProxy/Identity/InviteTokenClaimsPrincipalEnricher.cs index 78a45f9c..e6085c82 100644 --- a/Source/AuthProxy/Identity/InviteTokenClaimsPrincipalEnricher.cs +++ b/Source/AuthProxy/Identity/InviteTokenClaimsPrincipalEnricher.cs @@ -1,6 +1,7 @@ // 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 Cratis.AuthProxy.Invites; using Microsoft.Extensions.Options; using C = Cratis.AuthProxy.Configuration; @@ -52,16 +53,13 @@ public ClientPrincipal Enrich(HttpContext context, ClientPrincipal principal) ? mapping.FromClaimType : mapping.ToClaimType; - if (claims.Exists(_ => _.Type == targetClaimType && _.Value == value)) + var claim = new ClientPrincipalClaim { Type = targetClaimType, Value = value }; + if (SessionClaims.IsForwardedSessionContext(targetClaimType) || IsDuplicate(claims, claim)) { continue; } - claims.Add(new ClientPrincipalClaim - { - Type = targetClaimType, - Value = value, - }); + claims.Add(claim); changed = true; } @@ -80,4 +78,7 @@ public ClientPrincipal Enrich(HttpContext context, ClientPrincipal principal) AccountProvider = principal.AccountProvider, }; } + + static bool IsDuplicate(List claims, ClientPrincipalClaim claim) => + claims.Exists(_ => _.Type == claim.Type && _.Value == claim.Value); }