Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
800426b
Support certificate and federated client credentials for OIDC providers
woksin Oct 1, 2026
5cd592b
Forward a per-audience access token for the signed-in user to backends
woksin Oct 1, 2026
a51715c
Merge remote-tracking branch 'origin/main' into feature/155-forward-u…
woksin Oct 1, 2026
e6a2cf0
Merge remote-tracking branch 'origin/main' into feature/149-oidc-clie…
woksin Oct 1, 2026
a09800d
Fix OIDC assertion signing and credential reloads
woksin Oct 1, 2026
06b8a35
Protect forwarded-token sessions through refresh and sign-in
woksin Oct 1, 2026
4e36334
Resolve managed identity audiences from MSAL cloud metadata
woksin Oct 1, 2026
928f676
Throttle unavailable certificate reloads after expiry
woksin Oct 1, 2026
d236c49
Cache refused audience refreshes and retain active sliding token sess…
woksin Oct 1, 2026
d7326e0
Merge remote-tracking branch 'origin/main' into feature/149-oidc-clie…
woksin Oct 1, 2026
41d3cf2
Merge remote-tracking branch 'origin/main' into feature/155-forward-u…
woksin Oct 1, 2026
bf7ca95
Preserve whitespace-only OIDC certificate passwords
woksin Oct 1, 2026
13d37fd
Remove trailing blank line from access-token documentation
woksin Oct 1, 2026
402c8f0
Merge remote-tracking branch 'origin/main' into feature/149-oidc-clie…
woksin Oct 1, 2026
09ed6cf
Merge remote-tracking branch 'origin/main' into feature/155-forward-u…
woksin Oct 1, 2026
b6c03df
Initialize authentication services in streaming specs
woksin Oct 1, 2026
8b35fa0
Merge remote-tracking branch 'origin/main' into feature/155-forward-u…
woksin Oct 1, 2026
6f4423c
Merge origin/main into user access token forwarding
woksin Oct 1, 2026
78dbbc6
Merge remote-tracking branch 'origin/feature/149-oidc-client-credenti…
woksin Oct 1, 2026
4fec1a9
Bind forwarded user tokens to versioned backend configuration
woksin Oct 1, 2026
db82896
Merge branch 'main' into feature/155-forward-user-access-tokens
woksin Oct 2, 2026
e081b20
Preserve routed service recognition for token-forwarding backends
woksin Oct 2, 2026
182eb50
Merge origin/main into access token forwarding branch
woksin Oct 2, 2026
6271850
Reject mismatched token policy and destination snapshots
woksin Oct 2, 2026
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
103 changes: 103 additions & 0 deletions Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <token>`:

```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/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"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.
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <param name="harness">The running proxy and its origins.</param>
[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);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<ClientCredentialsTokenProtector>().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<string, string?>
{
[$"{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<AuthenticationOptions>(options =>
{
options.DefaultScheme = ClientCredentialsDefaults.CompositeAuthenticationScheme;
options.DefaultChallengeScheme = ClientCredentialsDefaults.AuthenticationScheme;
}));
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A token awaited for the old backend must never be forwarded to a newly configured origin.
/// </summary>
public class when_the_backend_changes_during_token_acquisition : IAsyncLifetime
{
readonly ReloadingHarness _harness = new();
HttpClient? _client;
HttpRequestMessage? _oldRequest;
Task<HttpResponseMessage>? _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<EndpointDataSource>();
_ = endpoints.Endpoints;
var reloaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var changed = endpoints.GetChangeToken().RegisterChangeCallback(_ => reloaded.TrySetResult(), null);
var config = (IConfigurationRoot)_harness.Services.GetRequiredService<IConfiguration>();
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<IUserAccessTokens>(Tokens));
}
}

sealed class BlockingTokens : IUserAccessTokens
{
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Resume { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);

public async Task<UserAccessTokenResult> 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()}");
}
}
}
Loading