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
67 changes: 65 additions & 2 deletions Documentation/configuration/authentication.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Authentication

AuthProxy supports two authentication modes that can be active simultaneously:
AuthProxy supports three authentication modes that can be active simultaneously:

- **Interactive browser sessions** – OpenID Connect (OIDC) with a cookie.
- **Machine-to-machine / API** – JWT Bearer tokens.
- **Machine-to-machine / API** – JWT Bearer tokens from an external identity provider.
- **Back-channel client credentials** – service-owned client credentials verified by the target service itself.

---

Expand Down Expand Up @@ -122,3 +123,65 @@ For machine-to-machine calls, configure a JWT Bearer handler:
}
```

---

## Back-channel client credentials

AuthProxy can also issue bearer tokens itself after a proxied service verifies the supplied
client credentials over a private back channel.

1. The client sends `POST /.cratis/token`
2. The request body uses standard OAuth form fields:
- `grant_type=client_credentials`
- `service=<service-key>` (optional when only one service has client credentials configured)
- `client_id=<client-id>`
- `client_secret=<client-secret>`
3. AuthProxy calls the configured downstream verification endpoint with a JSON payload:

```json
{
"service": "portal",
"routePrefix": "/api",
"clientId": "orders-api",
"clientSecret": "<client-secret>"
}
```

4. Any `2xx` response mints a bearer token scoped to that service and route prefix
5. Any `4xx` response rejects the credentials
6. Any `5xx` response is treated as a downstream verification failure

Successful responses from `/.cratis/token` look like this:

```json
{
"access_token": "<authproxy-issued-token>",
"token_type": "Bearer",
"expires_in": 3600
}
```

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.

### 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,
requires mounting a persistent, shared volume and pointing `Cratis:AuthProxy:DataProtectionKeysPath`
at it:

```json
{
"Cratis": {
"AuthProxy": {
"DataProtectionKeysPath": "/mnt/dataprotection-keys"
}
}
}
```

Without this, a client-credentials token minted by one replica will fail to validate on another,
and all outstanding tokens and sessions are invalidated on every restart.
3 changes: 2 additions & 1 deletion Documentation/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o
"Tenants": { ... },
"Services": { ... },
"Invite": { ... },
"PagesPath": ""
"PagesPath": "",
"DataProtectionKeysPath": ""
}
}
}
Expand Down
30 changes: 28 additions & 2 deletions Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n

```json
{
"Cratis": { {
"Cratis": {
"Services": {
"portal": {
"Backend": { "BaseUrl": "http://portal-api:8080/" },
"Frontend": { "BaseUrl": "http://portal-web:3000/" },
"ResolveIdentityDetails": true
"ResolveIdentityDetails": true,
"ClientCredentials": {
"RoutePrefix": "/api",
"VerificationPath": "/.cratis/client-credentials/verify"
}
},
"catalog": {
"Backend": { "BaseUrl": "http://catalog-api:8080/" }
Expand All @@ -33,13 +37,21 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n
| `Backend` | `ServiceEndpointConfig` | `null` | API backend endpoint. |
| `Frontend` | `ServiceEndpointConfig` | `null` | SPA / static-asset frontend endpoint. |
| `ResolveIdentityDetails` | `bool?` | `true` when Backend is set | Whether to call `/.cratis/me` on this service to enrich the identity cookie. |
| `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. |

### ServiceEndpointConfig properties

| Property | Type | Description |
|----------|------|-------------|
| `BaseUrl` | `string` | Base URL of the endpoint (e.g. `http://my-service:8080/`). |

### ServiceClientCredentialsConfig properties

| Property | Type | Description |
|----------|------|-------------|
| `RoutePrefix` | `string` | Route prefix that AuthProxy-issued bearer tokens are allowed to access (for example `/api`). |
| `VerificationPath` | `string` | Internal verification endpoint. Relative values are resolved against `Backend.BaseUrl`; absolute values are used as-is. |

---

## Routing
Expand Down Expand Up @@ -72,3 +84,17 @@ For each service with a `Backend` endpoint (and `ResolveIdentityDetails` not exp
The response is stored in a short-lived HTTP-only cookie (`.cratis-identity`) and injected as
the `X-MS-CLIENT-PRINCIPAL` header on every proxied request so that backend services can read
identity details without re-calling the identity endpoint themselves.

---

## Client credentials

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`.

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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_is_used_for_another_service : 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",

[$"{C.AuthProxy.SectionKey}:Services:catalog:Backend:BaseUrl"] = "http://catalog.test/",
[$"{C.AuthProxy.SectionKey}:Services:catalog:ClientCredentials:RoutePrefix"] = "/api",
[$"{C.AuthProxy.SectionKey}:Services:catalog: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();
context.Request.Headers[Headers.ServiceId] = "catalog";

_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,45 @@
// 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_matches_the_target_service_and_route : 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_expose_the_client_id_as_the_subject() => _result.Principal!.FindFirst("sub")!.Value.ShouldEqual("orders-api");
[Fact] void should_scope_the_principal_to_the_service() => _result.Principal!.FindFirst(ClientCredentialsDefaults.ServiceClaimType)!.Value.ShouldEqual("portal");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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.Net.Http.Json;
using Cratis.AuthProxy.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

/// <summary>
/// Web application factory for exercising the back-channel client-credentials token endpoint.
/// </summary>
public class AuthProxyFactory : WebApplicationFactory<Program>
{
public const string TenantId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";

public ClientCredentialsVerificationRequest? CapturedVerificationRequest { get; private set; }

protected virtual HttpStatusCode VerificationStatusCode => HttpStatusCode.NoContent;

/// <inheritdoc/>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified),
[$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId,

[$"{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",

[$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Microsoft",
[$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.com/common/v2.0",
[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "browser-client",
});
});

builder.ConfigureTestServices(services =>
{
services.AddSingleton<IHttpClientFactory>(new TestHttpClientFactory(async request =>
{
if (request.Content is not null)
{
CapturedVerificationRequest = await request.Content.ReadFromJsonAsync<ClientCredentialsVerificationRequest>();
}

return new HttpResponseMessage(VerificationStatusCode);
}));
});
}

/// <summary>
/// Creates a test HTTP client that does not follow redirects.
/// </summary>
/// <returns>A configured <see cref="HttpClient"/>.</returns>
public HttpClient CreateTestClient() =>
CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

sealed class TestHttpClientFactory(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) =>
new(new DispatchingHandler(handler)) { Timeout = TimeSpan.FromSeconds(10) };

sealed class DispatchingHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
handler(request);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

[CollectionDefinition(Name, DisableParallelization = true)]
public static class ClientCredentialsScenarioCollection
{
public const string Name = "client-credentials-scenarios";
}
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 System.Net;

namespace Cratis.AuthProxy.Scenarios.when_client_credentials_token_is_requested;

public class RejectedAuthProxyFactory : AuthProxyFactory
{
protected override HttpStatusCode VerificationStatusCode => HttpStatusCode.Unauthorized;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

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_credentials_are_rejected : Specification
{
HttpResponseMessage _response;
JsonDocument _payload;

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

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

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

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