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
66 changes: 62 additions & 4 deletions Documentation/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,77 @@ Successful responses from `/.cratis/token` look like this:
{
"access_token": "<authproxy-issued-token>",
"token_type": "Bearer",
"expires_in": 3600
"expires_in": 3600,
"refresh_token": "<authproxy-issued-refresh-token>"
}
```

The issued bearer token can then be used on the configured route prefix (for example `/api/**`).
AuthProxy validates that the token is used against the same configured service and route before
forwarding the request.

### Resolving a tenant from the verification response

The `2xx` response from the verification endpoint may optionally include a JSON body with a `tenant` property:

```json
{
"tenant": "acme"
}
```

When present, AuthProxy embeds that value in the minted access token (and any refresh token issued
alongside it) as a `cratis/tenant` claim. The claim travels with the token for its entire lifetime, so
every subsequent request authenticated with that token carries it.

To have AuthProxy resolve the tenant and set the `Tenant-ID` header on proxied requests, add a `Claim`
[tenant resolution strategy](tenancy.md#claim-strategy-options) pointing at that claim type:

```json
{
"Cratis": {
"AuthProxy": {
"TenantResolutions": [
{ "Strategy": "Claim", "Options": { "ClaimType": "cratis/tenant" } }
]
}
}
}
```

Like every other `Claim`-resolved value, the tenant returned by the verification endpoint is matched
against the `SourceIdentifiers` configured for each entry in `Cratis:AuthProxy:Tenants` — it is not
used directly as the Cratis tenant ID unless a tenant also lists it as one of its own source identifiers.
See [Tenant registry](tenancy.md#tenant-registry) for how that mapping works.

### Refreshing a token

A client can exchange a refresh token for a new access token without resupplying its client
credentials:

1. The client sends `POST /.cratis/token`
2. The request body uses:
- `grant_type=refresh_token`
- `refresh_token=<refresh-token>`
3. AuthProxy validates the refresh token and, if it is still valid, mints a new access token
and a new refresh token for the same service, client, and tenant — the response shape is
identical to the one shown above.

Refresh tokens are valid for 30 days and are not re-verified against the downstream service on
refresh — since the client secret is not resent, AuthProxy trusts the refresh token itself rather
than calling back to the target service. There is no revocation list: a leaked refresh token
remains usable until it naturally expires, so treat it as a credential and keep its exposure to the
same standard as a client secret.

An expired or unrecognized refresh token is rejected with `401 Unauthorized` and
`error: "invalid_grant"`. Refresh tokens cannot be used as access tokens (and vice versa) — each is
protected separately, so presenting one where the other is expected is always rejected.

### Data Protection keys and horizontal scaling

Both the authentication cookie and AuthProxy-issued client-credentials bearer tokens are encrypted
using ASP.NET Core Data Protection. By default, keys are not shared across instances. Running more
than one AuthProxy replica, or needing sessions and client-credentials tokens to survive a restart,
The authentication cookie and AuthProxy-issued client-credentials access and refresh tokens are all
encrypted using ASP.NET Core Data Protection. By default, keys are not shared across instances. Running
more than one AuthProxy replica, or needing sessions and client-credentials tokens to survive a restart,
requires mounting a persistent, shared volume and pointing `Cratis:AuthProxy:DataProtectionKeysPath`
at it:

Expand Down
8 changes: 7 additions & 1 deletion Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,16 @@ identity details without re-calling the identity endpoint themselves.

When `ClientCredentials` is configured for a service, AuthProxy exposes `POST /.cratis/token`.
That endpoint forwards the supplied client credentials to the service's verification endpoint and,
on success, issues a bearer token scoped to the configured `RoutePrefix`.
on success, issues a bearer token scoped to the configured `RoutePrefix`, along with a refresh token
that can later be exchanged for a new access token without resupplying the client credentials.

This creates a one-to-one relationship between:

- the proxied service
- the route prefix the token may access
- the downstream endpoint that verifies the client credentials

The verification endpoint's response can optionally include a `tenant` property, which AuthProxy then
carries on the issued tokens and can resolve into the `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.
4 changes: 4 additions & 0 deletions Documentation/configuration/tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Configure them under `Cratis:AuthProxy:TenantResolutions`:

If `ClaimType` is omitted, AuthProxy falls back to reading `X-MS-CLIENT-PRINCIPAL`.

This is also how you resolve a tenant carried on an AuthProxy-issued client-credentials bearer token —
point `ClaimType` at `cratis/tenant`. See
[Resolving a tenant from the verification response](authentication.md#resolving-a-tenant-from-the-verification-response).

---

## Route strategy options
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsBearerAuthenticationHandler;

public class when_a_refresh_token_is_used_as_an_access_token : Specification
{
AuthenticateResult _result;

async Task Establish()
{
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify",
});

builder.AddIngressConfiguration();
builder.AddIngressAuthentication();

var serviceProvider = builder.Services.BuildServiceProvider();
var tokenProtector = serviceProvider.GetRequiredService<ClientCredentialsTokenProtector>();
var resolver = serviceProvider.GetRequiredService<ClientCredentialsServiceResolver>();
resolver.TryResolveForTokenRequest("portal", out var service, out _).ShouldBeTrue();

var context = new DefaultHttpContext { RequestServices = serviceProvider };
context.Request.Path = "/api/orders";
var refreshToken = tokenProtector.CreateRefreshToken(service, "orders-api");
context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", refreshToken).ToString();

_result = await context.AuthenticateAsync(ClientCredentialsDefaults.AuthenticationScheme);
}

[Fact] void should_reject_the_request() => _result.Succeeded.ShouldBeFalse();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsBearerAuthenticationHandler;

public class when_token_carries_a_tenant : Specification
{
AuthenticateResult _result;

async Task Establish()
{
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify",
});

builder.AddIngressConfiguration();
builder.AddIngressAuthentication();

var serviceProvider = builder.Services.BuildServiceProvider();
var tokenProtector = serviceProvider.GetRequiredService<ClientCredentialsTokenProtector>();
var resolver = serviceProvider.GetRequiredService<ClientCredentialsServiceResolver>();
resolver.TryResolveForTokenRequest("portal", out var service, out _).ShouldBeTrue();

var context = new DefaultHttpContext { RequestServices = serviceProvider };
context.Request.Path = "/api/orders";
var token = tokenProtector.CreateToken(service, "orders-api", "acme");
context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token).ToString();

_result = await context.AuthenticateAsync(ClientCredentialsDefaults.AuthenticationScheme);
}

[Fact] void should_authenticate_the_request() => _result.Succeeded.ShouldBeTrue();
[Fact] void should_expose_the_tenant_claim() => _result.Principal!.FindFirst(ClientCredentialsDefaults.TenantClaimType)!.Value.ShouldEqual("acme");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.AuthProxy.Authentication.for_ClientCredentialsBearerAuthenticationHandler;

public class when_token_does_not_carry_a_tenant : Specification
{
AuthenticateResult _result;

async Task Establish()
{
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{C.AuthProxy.SectionKey}:Services:portal:Backend:BaseUrl"] = "http://portal.test/",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:RoutePrefix"] = "/api",
[$"{C.AuthProxy.SectionKey}:Services:portal:ClientCredentials:VerificationPath"] = "/.cratis/client-credentials/verify",
});

builder.AddIngressConfiguration();
builder.AddIngressAuthentication();

var serviceProvider = builder.Services.BuildServiceProvider();
var tokenProtector = serviceProvider.GetRequiredService<ClientCredentialsTokenProtector>();
var resolver = serviceProvider.GetRequiredService<ClientCredentialsServiceResolver>();
resolver.TryResolveForTokenRequest("portal", out var service, out _).ShouldBeTrue();

var context = new DefaultHttpContext { RequestServices = serviceProvider };
context.Request.Path = "/api/orders";
var token = tokenProtector.CreateToken(service, "orders-api");
context.Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token).ToString();

_result = await context.AuthenticateAsync(ClientCredentialsDefaults.AuthenticationScheme);
}

[Fact] void should_authenticate_the_request() => _result.Succeeded.ShouldBeTrue();
[Fact] void should_not_expose_a_tenant_claim() => _result.Principal!.FindFirst(ClientCredentialsDefaults.TenantClaimType).ShouldBeNull();
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class AuthProxyFactory : WebApplicationFactory<Program>

protected virtual HttpStatusCode VerificationStatusCode => HttpStatusCode.NoContent;

protected virtual object? VerificationResponseBody => null;

/// <inheritdoc/>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Expand Down Expand Up @@ -52,7 +54,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
CapturedVerificationRequest = await request.Content.ReadFromJsonAsync<ClientCredentialsVerificationRequest>();
}

return new HttpResponseMessage(VerificationStatusCode);
var response = new HttpResponseMessage(VerificationStatusCode);
if (VerificationResponseBody is not null)
{
response.Content = JsonContent.Create(VerificationResponseBody);
}

return response;
}));
});
}
Expand Down
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.

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

public class TenantAuthProxyFactory : AuthProxyFactory
{
public const string VerifiedTenant = "acme";

protected override object? VerificationResponseBody => new { tenant = VerifiedTenant };
}
Original file line number Diff line number Diff line change
@@ -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 System.Net;
using System.Text.Json;
using Cratis.AuthProxy.Authentication;

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

[Collection(ClientCredentialsScenarioCollection.Name)]
public class and_a_valid_refresh_token_is_presented : Specification
{
HttpResponseMessage _response;
JsonDocument _payload;
string _originalAccessToken;

async Task Establish()
{
var factory = new AuthProxyFactory();
using var client = factory.CreateTestClient();

var initialResponse = await client.PostAsync(
WellKnownPaths.Token,
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = ClientCredentialsDefaults.GrantType,
["service"] = "portal",
["client_id"] = "orders-api",
["client_secret"] = "super-secret",
}));

var initialPayload = JsonDocument.Parse(await initialResponse.Content.ReadAsStringAsync());
_originalAccessToken = initialPayload.RootElement.GetProperty("access_token").GetString()!;
var refreshToken = initialPayload.RootElement.GetProperty("refresh_token").GetString()!;

_response = await client.PostAsync(
WellKnownPaths.Token,
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = ClientCredentialsDefaults.RefreshGrantType,
["refresh_token"] = refreshToken,
}));

_payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync());
}

[Fact] void should_return_ok() => _response.StatusCode.ShouldEqual(HttpStatusCode.OK);
[Fact] void should_return_a_new_access_token()
{
var accessToken = _payload.RootElement.GetProperty("access_token").GetString();
string.IsNullOrWhiteSpace(accessToken).ShouldBeFalse();
(accessToken == _originalAccessToken).ShouldBeFalse();
}
[Fact] void should_return_the_token_lifetime() => _payload.RootElement.GetProperty("expires_in").GetInt32().ShouldEqual(3600);
[Fact] void should_return_a_new_refresh_token() => string.IsNullOrWhiteSpace(_payload.RootElement.GetProperty("refresh_token").GetString()).ShouldBeFalse();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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;
using System.Text.Json;
using Cratis.AuthProxy.Authentication;

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

[Collection(ClientCredentialsScenarioCollection.Name)]
public class and_an_access_token_is_used_as_a_refresh_token : Specification
{
HttpResponseMessage _response;
JsonDocument _payload;

async Task Establish()
{
var factory = new AuthProxyFactory();
using var client = factory.CreateTestClient();

var initialResponse = await client.PostAsync(
WellKnownPaths.Token,
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = ClientCredentialsDefaults.GrantType,
["service"] = "portal",
["client_id"] = "orders-api",
["client_secret"] = "super-secret",
}));

var initialPayload = JsonDocument.Parse(await initialResponse.Content.ReadAsStringAsync());
var accessToken = initialPayload.RootElement.GetProperty("access_token").GetString()!;

_response = await client.PostAsync(
WellKnownPaths.Token,
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = ClientCredentialsDefaults.RefreshGrantType,
["refresh_token"] = accessToken,
}));

_payload = JsonDocument.Parse(await _response.Content.ReadAsStringAsync());
}

[Fact] void should_return_unauthorized() => _response.StatusCode.ShouldEqual(HttpStatusCode.Unauthorized);
[Fact] void should_return_an_invalid_grant_error() => _payload.RootElement.GetProperty("error").GetString().ShouldEqual("invalid_grant");
}
Loading
Loading