Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions Documentation/configuration/forwarded-identity-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>: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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>() == type)!["val"]!.GetValue<string>();
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<ForwardedRequest> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -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<string>()));
}
}
Original file line number Diff line number Diff line change
@@ -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<string>() == "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<string>() == 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 }));
}
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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<IUserTokenStore>().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<IUserAccessTokens>().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));
}
}
Loading
Loading