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
15 changes: 15 additions & 0 deletions Documentation/configuration/error-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ condition is detected:
| `404.html` | The requested resource was not found. | 404 |
| `403.html` | The identity resolver denied access. | 403 |
| `tenant-not-found.html` | The resolved tenant does not exist in the platform (see [Tenant verification](tenancy.md#tenant-verification)). | 404 |
| `select-tenant.html` | Tenant selection is enabled and the authenticated user has not selected a tenant yet. The page reads the `.cratis-tenants` cookie to render selectable tenants. | 200 |
| `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 |
| `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 |
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
Expand Down Expand Up @@ -154,3 +155,17 @@ providers.forEach(function(provider) {
`tenant-not-found.html` is served when tenant verification is enabled and the platform reports
that the resolved tenant ID does not exist. See [Tenant verification](tenancy.md#tenant-verification)
for how to configure the verification endpoint.

---

## Tenant selection page

`select-tenant.html` is served when the `Selection` tenant-resolution strategy is configured and no
`.cratis-tenant` cookie is present for the authenticated user.

See [Tenant Selection Page](tenant-selection.md) for:

- `TenantsEndpoint` configuration
- `.cratis-tenants` cookie schema (`id`, `name`)
- `/.cratis/select-tenant` selection callback behavior
- full custom-page example
1 change: 1 addition & 0 deletions Documentation/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o
|-------|-------------|
| [Authentication](authentication.md) | OIDC providers and JWT Bearer configuration. |
| [Tenancy](tenancy.md) | How the auth proxy resolves the current tenant from each request, and how to verify tenant existence. |
| [Tenant Selection Page](tenant-selection.md) | How selection-based tenant resolution works and how to build/override `select-tenant.html`. |
| [Services](services.md) | Routing requests to backend and frontend services. |
| [Invites & Lobby](invites.md) | Invite-based onboarding and the lobby service. |
| [Well-Known Pages](well-known-pages.md) | Built-in HTML pages (provider selection, errors, tenant not found) and how to override them via a mounted volume. |
23 changes: 23 additions & 0 deletions Documentation/configuration/tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Configure them under `Cratis:AuthProxy:TenantResolutions`:
| `Specified` | Resolves directly to a fixed tenant ID string from configuration. |
| `Default` | Resolves directly to a fallback tenant ID string from configuration. |
| `SubHost` | Resolves directly from subhost convention, for example `acme.example.com` -> `acme`. |
| `Selection` | Resolves from the selected-tenant cookie set by the tenant-selection page flow. |

---

Expand Down Expand Up @@ -151,6 +152,28 @@ See [Tenant verification](#tenant-verification) for full response handling detai

---

## Selection strategy options

```json
{
"Strategy": "Selection",
"Options": {
"TenantsEndpoint": "https://platform.example.com/api/tenants/selectable"
}
}
```

| Property | Type | Description |
|----------|------|-------------|
| `TenantsEndpoint` | `string` | Absolute URL for the endpoint that returns selectable tenants for the current authenticated user. Expected response shape is an array of `{ "id": "...", "name": "..." }` objects. |

When this strategy is configured and no `.cratis-tenant` cookie exists yet, AuthProxy calls
`TenantsEndpoint`, sets the `.cratis-tenants` cookie, and serves `select-tenant.html`.
The page links back to `/.cratis/select-tenant?tenantId=<id>&returnUrl=<path>`, and AuthProxy validates
the selected tenant against the endpoint response before writing the `.cratis-tenant` cookie.

---

## Tenant registry

For `Host`, `Claim`, and `Route`, AuthProxy resolves a source identifier and then looks up the tenant ID in `Cratis:AuthProxy:Tenants`.
Expand Down
116 changes: 116 additions & 0 deletions Documentation/configuration/tenant-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Tenant Selection Page

When tenant selection is configured, AuthProxy serves a tenant-selection page after login until the
user chooses a tenant.

The page receives tenant data through a cookie, not by calling your tenant endpoint directly.

---

## Flow

1. Authenticated request arrives without a `.cratis-tenant` cookie.
2. AuthProxy calls the configured `Selection.Options.TenantsEndpoint`.
3. If exactly one tenant is returned, AuthProxy sets `.cratis-tenant` immediately and redirects to the original URL (no selection page shown).
4. If more than one tenant is returned, AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) and serves `select-tenant.html`.
5. User clicks a tenant option.
6. Browser navigates to `/.cratis/select-tenant?tenantId=<id>&returnUrl=<path>`.
7. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`.
8. AuthProxy redirects back to `returnUrl`.

---

## Endpoint response shape

`TenantsEndpoint` must return JSON with one object per selectable tenant:

```json
[
{
"id": "some string",
"name": "some string"
}
]
```

---

## Cookie shape used by the page

AuthProxy writes `.cratis-tenants` with the same shape:

```json
[
{
"id": "studio",
"name": "Cratis Studio"
},
{
"id": "sales",
"name": "Sales Portal"
}
]
```

---

## Building a custom `select-tenant.html`

`select-tenant.html` is a normal overridable page in `PagesPath`, exactly like the other built-in pages.

Minimum requirements:

1. Read `.cratis-tenants` from `document.cookie`.
2. Parse JSON and render `name`.
3. Link each item to `/.cratis/select-tenant?tenantId=<id>&returnUrl=<current path>`.

Example:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Select tenant</title>
</head>
<body>
<h1>Select tenant</h1>
<ul id="tenants"></ul>
<p id="error" hidden>Unable to load tenant options.</p>

<script>
(function () {
function getCookie(name) {
var pattern = new RegExp('(?:^|; )' +
name.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1') + '=([^;]*)');
var match = document.cookie.match(pattern);
return match ? decodeURIComponent(match[1]) : null;
}

var json = getCookie('.cratis-tenants');
var tenants;
try { tenants = JSON.parse(json); } catch (_) { tenants = null; }

if (!tenants || tenants.length === 0) {
document.getElementById('error').hidden = false;
return;
}

var returnUrl = window.location.pathname + window.location.search;
var list = document.getElementById('tenants');

tenants.forEach(function (tenant) {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '/.cratis/select-tenant?tenantId=' +
encodeURIComponent(tenant.id) +
'&returnUrl=' + encodeURIComponent(returnUrl);
a.textContent = tenant.name;
li.appendChild(a);
list.appendChild(li);
});
}());
</script>
</body>
</html>
```
2 changes: 2 additions & 0 deletions Documentation/configuration/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
href: authentication.md
- name: Tenancy
href: tenancy.md
- name: Tenant Selection Page
href: tenant-selection.md
- name: Services
href: services.md
- name: Invites & Lobby
Expand Down
1 change: 1 addition & 0 deletions Documentation/configuration/well-known-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ condition is detected:
| `403.html` | The identity resolver denied access. | 403 |
| `tenant-not-found.html` | The resolved tenant does not exist in the platform (see [Tenant verification](tenancy.md#tenant-verification)). | 404 |
| `select-provider.html` | A protected resource was requested and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
| `select-tenant.html` | Tenant selection is enabled and the authenticated user has not selected a tenant yet. The page reads the `.cratis-tenants` cookie to render selectable tenants. | 200 |
| `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 |
| `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 |
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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.Tenancy.for_SelectionSourceIdentifierStrategy;

public class when_tenant_cookie_is_not_present : Specification
{
SelectionSourceIdentifierStrategy _strategy;
DefaultHttpContext _context;
bool _succeeded;
string _sourceIdentifier = "initial";

void Establish()
{
_strategy = new SelectionSourceIdentifierStrategy();
_context = new DefaultHttpContext();
}

void Because() => _succeeded = _strategy.TryResolveSourceIdentifier(_context, new SelectionOptions(), out _sourceIdentifier);

[Fact] void should_not_succeed() => _succeeded.ShouldBeFalse();
[Fact] void should_return_empty_source_identifier() => _sourceIdentifier.ShouldEqual(string.Empty);
}
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.Tenancy.for_SelectionSourceIdentifierStrategy;

public class when_tenant_cookie_is_present : Specification
{
const string TenantId = "tenant-a";

SelectionSourceIdentifierStrategy _strategy;
DefaultHttpContext _context;
bool _succeeded;
string _sourceIdentifier = string.Empty;

void Establish()
{
_strategy = new SelectionSourceIdentifierStrategy();
_context = new DefaultHttpContext();
_context.Request.Headers.Cookie = $"{Cookies.Tenant}={TenantId}";
}

void Because() => _succeeded = _strategy.TryResolveSourceIdentifier(_context, new SelectionOptions(), out _sourceIdentifier);

[Fact] void should_succeed() => _succeeded.ShouldBeTrue();
[Fact] void should_resolve_the_tenant_id() => _sourceIdentifier.ShouldEqual(TenantId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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;

namespace Cratis.AuthProxy.for_TenantSelectionMiddleware;

public class when_authenticated_user_has_no_resolved_tenant_and_only_one_tenant_is_available : Specification
{
TenantSelectionMiddleware _middleware;
DefaultHttpContext _context;
IErrorPageProvider _errorPageProvider;
bool _nextCalled;

void Establish()
{
var authProxyConfig = new C.AuthProxy
{
TenantResolutions =
[
new C.TenantResolution
{
Strategy = C.TenantSourceIdentifierResolverType.Selection,
Options = new SelectionOptions
{
TenantsEndpoint = "https://platform.example.com/api/tenants/selectable"
}
}
]
};
var config = Substitute.For<IOptionsMonitor<C.AuthProxy>>();
config.CurrentValue.Returns(authProxyConfig);

var tenantResolver = Substitute.For<ITenantResolver>();
tenantResolver.TryResolve(Arg.Any<HttpContext>(), out Arg.Any<string>()).Returns(false);

var httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>())
.Returns(new HttpClient(new FakeSingleTenantHandler()));

_errorPageProvider = Substitute.For<IErrorPageProvider>();
_errorPageProvider
.WriteErrorPageAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.CompletedTask);

_middleware = new TenantSelectionMiddleware(
_ =>
{
_nextCalled = true;
return Task.CompletedTask;
},
config,
tenantResolver,
httpClientFactory,
_errorPageProvider);

_context = new DefaultHttpContext();
_context.Request.Path = "/products";
_context.Request.QueryString = new QueryString("?view=list");
_context.Response.Body = new MemoryStream();
_context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("oid", "user-id")], "aad"));
}

async Task Because() => await _middleware.InvokeAsync(_context);

[Fact] void should_set_tenant_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Tenant}=tenant-a");
[Fact] void should_redirect_to_original_path() => _context.Response.Headers.Location.ToString().ShouldEqual("/products?view=list");
[Fact] void should_not_serve_select_tenant_page() => _errorPageProvider.DidNotReceive().WriteErrorPageAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<int>());
[Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse();

sealed class FakeSingleTenantHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""[{"id":"tenant-a","name":"Tenant A"}]""",
Encoding.UTF8,
"application/json")
});
}
}
Loading