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
9 changes: 6 additions & 3 deletions Documentation/configuration/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,12 @@ an extra check would quietly drop the organization check, and a service added la
would be the way in.

Which service a request targets is worked out the same way the [route table](services.md) works it out: the
single configured service when there is only one, otherwise the `x-cratis-microservice` header (or the legacy `Service-ID`) or the `service` query
parameter. A request in a multi-service deployment that names neither reaches no service route either, so
only the root requirements apply to it.
service whose [host or path prefix](services.md#routing-by-host-or-path-prefix) the request matches, the
service named by the `x-cratis-microservice` header (or the legacy `Service-ID`) or the `service` query
parameter, or the single configured service when there is only one, in the precedence the route table uses.
A request in a multi-service deployment that matches no service reaches no service route either. When such
a request still names a configured service, that service's requirements apply anyway. An unknown header
does not hide a query-selected service's requirements. Otherwise only the root requirements apply.

---

Expand Down
114 changes: 109 additions & 5 deletions Documentation/configuration/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ 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. |
| `Hosts` | `string[]` | `[]` | Host names (with an optional port) that route to this service. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). |
| `PathPrefix` | `string` | `""` | Path prefix that routes to this service, for example `/reporting`. See [Routing by host or path prefix](#routing-by-host-or-path-prefix). |
| `StripPathPrefix` | `bool` | `false` | Remove `PathPrefix` from the forwarded path and send it in `X-Forwarded-Prefix`. |
| `ResolveIdentityDetails` | `bool?` | `true` when Backend is set | Whether to call `/.cratis/me` on this service **at all**. See [Identity enrichment](#identity-enrichment). |
| `IdentityVerification` | `BestEffort` \| `Required` | `BestEffort` | What that call's answer **means**. See [Identity enrichment](#identity-enrichment). |
| `IdentityVerificationTimeout` | `TimeSpan` | `00:00:10` under `Required`, unbounded under `BestEffort` | How long to wait for the answer. Zero or negative leaves the wait unbounded. See [Two settings, two questions](#two-settings-two-questions). |
Expand Down Expand Up @@ -64,15 +67,16 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n

### Single service

When only one service is configured, AuthProxy adds a plain catch-all route so the service
is reachable without any special routing header or query parameter.
When only one service is configured, and it declares neither `Hosts` nor a `PathPrefix`, AuthProxy adds a
plain catch-all route so the service is reachable without any special routing header or query parameter.

- `/{**path}` → frontend
- `/api/{**path}` → backend

### Multiple services

With more than one service, clients must indicate the target using one of:
With more than one service, a request reaches a service when the service declares the request's host or
path prefix (see below), or when the client names the service with one of:

| Mechanism | Example |
|-----------|---------|
Expand All @@ -91,7 +95,101 @@ The selected identifier is forwarded under both header names, including when sel
Forwarding `Service-ID` is deprecated and will be removed in a future major release; move backends to
`x-cratis-microservice`.

Routes are matched case-insensitively.
Routes are matched case-insensitively. Within a service, `/api/...` goes to the backend and everything else
goes to the frontend. With host routing, path-prefix routing or the unrestricted single-service catch-all,
a service with only a backend receives every path within that route. Header and query selection of a
backend-only service only match `/api/...`.

### Routing by host or path prefix

A browser cannot put a header on a top-level navigation, and adding `?service=` to every URL (assets, deep
links, bookmarks) is impractical. To put several applications behind one AuthProxy, and so behind one
sign-in, give each service a host, a path prefix, or both:

```json
{
"Cratis": {
"AuthProxy": {
"Services": {
"portal": {
"Hosts": [ "portal.example.com" ],
"Frontend": { "BaseUrl": "http://portal-web:3000/" },
"Backend": { "BaseUrl": "http://portal-api:8080/" }
},
"reporting": {
"PathPrefix": "/reporting",
"StripPathPrefix": true,
"Frontend": { "BaseUrl": "http://reporting-web:3000/" },
"Backend": { "BaseUrl": "http://reporting-api:8080/" },
"ClientCredentials": { "RoutePrefix": "/reporting/api" }
}
}
}
}
}
```

Here `https://portal.example.com/orders` goes to the portal frontend, `https://portal.example.com/api/orders` to
the portal backend, `https://portal.example.com/reporting/api/sales` to the reporting backend as `/api/sales`,
and `https://any-host/reporting/dashboard` to the reporting frontend as `/dashboard`.

#### Precedence

When more than one rule could match a request, the first one in this list wins:

1. [Anonymous paths](#anonymous-paths).
2. A `PathPrefix` on one of the service's `Hosts`.
3. A `PathPrefix` on a service without `Hosts`, which matches on every host.
4. The `x-cratis-microservice` header (or legacy `Service-ID`), then the `service` query parameter.
5. `Hosts` on a service without a `PathPrefix`.
6. The single-service catch-all routes.

A path prefix claims its part of the URL, so it wins over a header or query parameter naming another
service. A host is only a default for the requests on it: a frontend served from `portal.example.com` can
still call another service's backend by naming it in `x-cratis-microservice`, as Arc frontends do.

The service a request is routed to is also the service whose [authorization requirements](authorization.md)
apply to it, and the only service whose [client-credentials](#client-credentials) tokens it accepts. The
checks use the selected proxy route, so a host or prefix cannot be used to reach a service without
meeting its requirements. Client-credentials `RoutePrefix` must include the external path prefix; see
[Client credentials](#client-credentials).

#### Ambiguous matches fail at startup

AuthProxy refuses to start, and names the services involved, when:

- two services without a `PathPrefix` declare the same host (`example.com` without a port overlaps every
`example.com:port`);
- two services declare equal or nested path prefixes (`/reports` and `/reports/archive`) on the same hosts, or
both on every host;
- a `Hosts` entry is not a host name with an optional numeric port. URLs, paths, IPv6 literals and wildcards
(`*.example.com`) are refused;
- a `PathPrefix` is not a rooted path of literal segments, is `/api` or below it, or covers a path AuthProxy
reserves for itself (`/.cratis`, `/_pages`, `/invite`, `/register`, `/signin-*`);
- a service declares `Hosts` or a `PathPrefix` but has no `Backend` or `Frontend`, or sets `StripPathPrefix`
without a `PathPrefix`.

A prefix on some hosts and a prefix on every host may overlap. The host-specific one wins on its hosts.

#### Keeping or stripping the prefix

By default the service receives the path as requested, `/reporting/api/sales`, and serves itself under the
prefix. In ASP.NET Core that is `app.UsePathBase("/reporting")`, and a single-page frontend builds with the
same base path.

With `StripPathPrefix` the prefix is removed: the service receives `/api/sales`, and AuthProxy sends the
removed prefix in `X-Forwarded-Prefix`. A backend that honors forwarded headers restores the removed prefix
as its path base, so links and redirects it generates still point under `/reporting`. An `X-Forwarded-Prefix`
sent by a proxy in front of AuthProxy is replaced, not combined. [Anonymous paths](#anonymous-paths) below a stripped prefix are
stripped too. Declare them with the full path, for example `/reporting/public`.

AuthProxy's own endpoints (`/.cratis/login`, `/.cratis/select-provider`, `/.cratis/logout` and the other
`/.cratis/*` paths it answers itself) stay at the root on every host. A frontend served under a prefix calls
them at the root. `/reporting/.cratis/me` is forwarded to the reporting service like any other path under
its prefix.

WebSocket upgrades and server-sent events follow the same routes as any other request.


---

Expand Down Expand Up @@ -256,7 +354,8 @@ that still returns the selection page can be diagnosed from the log rather than

`/api` chooses the endpoint the same way the authenticated routes do: a prefix under `/api` is served by
the service's `Backend`, anything else by its `Frontend`, falling back to whichever endpoint the service
actually declares.
actually declares. Under a service's `PathPrefix`, the same split applies relative to that prefix: an
anonymous `/reporting/api/webhook` goes to the reporting backend.

### What it does and does not change

Expand Down Expand Up @@ -482,6 +581,11 @@ That endpoint forwards the supplied client credentials to the service's verifica
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.

`RoutePrefix` defaults to `/api` and is checked against the incoming path, before `StripPathPrefix` removes
anything. For a service with `PathPrefix: /reporting`, set `ClientCredentials.RoutePrefix` to
`/reporting/api` to accept bearer tokens on its API routes (or another explicitly permitted external
prefix). Host routing can distinguish services that share the same `RoutePrefix`.

This creates a one-to-one relationship between:

- the proxied service
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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_ServiceRouting;

/// <summary>
/// A punycode declaration must match the Unicode host ASP.NET exposes from the wire-format Host header.
/// </summary>
/// <param name="harness">The running proxy and its origins.</param>
[Collection(ServiceRoutingSpecCollection.Name)]
public class when_a_punycode_host_is_requested(ServiceRoutingHarness harness) : IAsyncLifetime
{
HttpResponseMessage? _response;
ForwardedRequest? _forwarded;

public async Task InitializeAsync()
{
using var client = harness.CreateSecurityClient();
harness.ClearOrigins();
using var request = ServiceRoutingHarness.Request("/api/books", "xn--bcher-kva.example.test");
_response = await client.SendAsync(request);
_forwarded = harness.Portal.LastRequestTo("/api/books");
}

public Task DisposeAsync()
{
_response?.Dispose();
return Task.CompletedTask;
}

[Fact] public void should_match_the_host_route() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode);
[Fact] public void should_forward_to_the_declaring_service() => Assert.NotNull(_forwarded);
[Fact] public void should_forward_the_selected_service_identifier() => Assert.Equal("portal", _forwarded!.Value(Headers.ServiceId));
}
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.

namespace Cratis.AuthProxy.Security.for_ServiceRouting;

/// <summary>
/// YARP splits and unquotes header values. Authorization must apply to the selected route, not the raw header
/// or the less restricted service whose host was requested.
/// </summary>
/// <param name="harness">The running proxy and its origins.</param>
[Collection(ServiceRoutingSpecCollection.Name)]
public class when_a_service_header_uses_yarp_value_syntax(ServiceRoutingHarness harness)
{
[Theory]
[InlineData("\"admin\"")]
[InlineData("admin,")]
[InlineData("unknown, admin")]
public async Task should_require_the_claim_of_the_selected_service(string header)
{
using var client = harness.CreateSecurityClient();
harness.ClearOrigins();
using var request = ServiceRoutingHarness.Request("/api/users", "portal.example.test");
request.Headers.TryAddWithoutValidation(Headers.ServiceId, header);
using var response = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.False(harness.Admin.ReceivedAnythingFor("/api/users"));
Assert.False(harness.Portal.ReceivedAnythingFor("/api/users"));
}

[Theory]
[InlineData("\"admin\"")]
[InlineData("admin,")]
[InlineData("unknown, admin")]
public async Task should_forward_a_qualified_caller_to_the_selected_service(string header)
{
using var client = harness.CreateSecurityClient();
harness.ClearOrigins();
using var request = ServiceRoutingHarness.Request("/api/users", "portal.example.test", withAdminClaim: true);
request.Headers.TryAddWithoutValidation(Headers.ServiceId, header);
using var response = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(harness.Admin.ReceivedAnythingFor("/api/users"));
Assert.False(harness.Portal.ReceivedAnythingFor("/api/users"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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_ServiceRouting;

/// <summary>
/// Anonymous API paths use the same backend and stripped path as authenticated API paths.
/// </summary>
/// <param name="harness">The running proxy and its distinct backend and frontend origins.</param>
[Collection(ServiceRoutingSpecCollection.Name)]
public class when_an_anonymous_api_is_under_a_stripped_prefix(ServiceRoutingHarness harness)
{
[Fact]
public async Task should_forward_to_the_backend_without_a_session()
{
using var client = harness.CreateSecurityClient();
harness.ClearOrigins();
using var response = await client.GetAsync($"{ServiceRoutingHarness.ReportsPrefix}/api/health");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var forwarded = harness.Reports.LastRequestTo("/api/health");
Assert.NotNull(forwarded);
Assert.Equal(ServiceRoutingHarness.ReportsPrefix, forwarded.Value("X-Forwarded-Prefix"));
Assert.False(harness.ReportsFrontend.ReceivedAnythingFor("/api/health"));
}
}
Loading