From fcaa7027964c262fd323372fe9eaea2c0ff1fc93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 04:08:21 +0000 Subject: [PATCH 1/4] Initial plan From 9779b7404964596d7cbd1a12d65191dd3332d8d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 04:19:04 +0000 Subject: [PATCH 2/4] Add selection-based tenant resolver with tenant selection page Agent-Logs-Url: https://github.com/Cratis/AuthProxy/sessions/8002ad56-7619-4213-b489-6d41e3194b1c Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Documentation/configuration/error-pages.md | 15 ++ Documentation/configuration/index.md | 1 + Documentation/configuration/tenancy.md | 23 +++ .../configuration/tenant-selection.md | 115 +++++++++++ Documentation/configuration/toc.yml | 2 + .../configuration/well-known-pages.md | 1 + .../when_tenant_cookie_is_not_present.cs | 23 +++ .../when_tenant_cookie_is_present.cs | 26 +++ ...nt_and_selection_strategy_is_configured.cs | 81 ++++++++ ...hen_request_is_authentication_bootstrap.cs | 50 +++++ .../when_selecting_a_tenant.cs | 75 +++++++ .../TenantSourceIdentifierResolverType.cs | 3 + Source/AuthProxy/Cookies.cs | 13 ++ Source/AuthProxy/IngressExtensions.cs | 1 + Source/AuthProxy/Pages/select-tenant.html | 68 ++++++ Source/AuthProxy/Tenancy/SelectionOptions.cs | 15 ++ .../SelectionSourceIdentifierStrategy.cs | 30 +++ .../TenancyServiceCollectionExtensions.cs | 1 + .../TenantResolutionOptionsConfigurer.cs | 1 + Source/AuthProxy/TenantResolver.cs | 17 +- Source/AuthProxy/TenantSelectionMiddleware.cs | 195 ++++++++++++++++++ Source/AuthProxy/WellKnownPageNames.cs | 6 + Source/AuthProxy/WellKnownPaths.cs | 6 +- 23 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 Documentation/configuration/tenant-selection.md create mode 100644 Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_not_present.cs create mode 100644 Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_present.cs create mode 100644 Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_selection_strategy_is_configured.cs create mode 100644 Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_request_is_authentication_bootstrap.cs create mode 100644 Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_selecting_a_tenant.cs create mode 100644 Source/AuthProxy/Pages/select-tenant.html create mode 100644 Source/AuthProxy/Tenancy/SelectionOptions.cs create mode 100644 Source/AuthProxy/Tenancy/SelectionSourceIdentifierStrategy.cs create mode 100644 Source/AuthProxy/TenantSelectionMiddleware.cs diff --git a/Documentation/configuration/error-pages.md b/Documentation/configuration/error-pages.md index a3d6a09..913da54 100644 --- a/Documentation/configuration/error-pages.md +++ b/Documentation/configuration/error-pages.md @@ -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 | @@ -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 diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md index cfe9839..16a850c 100644 --- a/Documentation/configuration/index.md +++ b/Documentation/configuration/index.md @@ -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. | diff --git a/Documentation/configuration/tenancy.md b/Documentation/configuration/tenancy.md index 2a83ba5..77c54fd 100644 --- a/Documentation/configuration/tenancy.md +++ b/Documentation/configuration/tenancy.md @@ -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. | --- @@ -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=&returnUrl=`, 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`. diff --git a/Documentation/configuration/tenant-selection.md b/Documentation/configuration/tenant-selection.md new file mode 100644 index 0000000..0901db0 --- /dev/null +++ b/Documentation/configuration/tenant-selection.md @@ -0,0 +1,115 @@ +# 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. AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) and serves `select-tenant.html`. +4. User clicks a tenant option. +5. Browser navigates to `/.cratis/select-tenant?tenantId=&returnUrl=`. +6. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`. +7. 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=&returnUrl=`. + +Example: + +```html + + + + + Select tenant + + +

Select tenant

+
    + + + + + +``` diff --git a/Documentation/configuration/toc.yml b/Documentation/configuration/toc.yml index af98029..5a41e3b 100644 --- a/Documentation/configuration/toc.yml +++ b/Documentation/configuration/toc.yml @@ -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 diff --git a/Documentation/configuration/well-known-pages.md b/Documentation/configuration/well-known-pages.md index 123d155..15f0c1e 100644 --- a/Documentation/configuration/well-known-pages.md +++ b/Documentation/configuration/well-known-pages.md @@ -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 | diff --git a/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_not_present.cs b/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_not_present.cs new file mode 100644 index 0000000..c00ee89 --- /dev/null +++ b/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_not_present.cs @@ -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); +} diff --git a/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_present.cs b/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_present.cs new file mode 100644 index 0000000..f297248 --- /dev/null +++ b/Source/AuthProxy.Specs/Tenancy/for_SelectionSourceIdentifierStrategy/when_tenant_cookie_is_present.cs @@ -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); +} diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_selection_strategy_is_configured.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_selection_strategy_is_configured.cs new file mode 100644 index 0000000..cee63a9 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_selection_strategy_is_configured.cs @@ -0,0 +1,81 @@ +// 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_selection_strategy_is_configured : 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>(); + config.CurrentValue.Returns(authProxyConfig); + + var tenantResolver = Substitute.For(); + tenantResolver.TryResolve(Arg.Any(), out Arg.Any()).Returns(false); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(new HttpClient(new FakeTenantsHandler())); + + _errorPageProvider = Substitute.For(); + _errorPageProvider + .WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _middleware = new TenantSelectionMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + config, + tenantResolver, + httpClientFactory, + _errorPageProvider); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/products"; + _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_tenants_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.Tenants); + [Fact] void should_serve_select_tenant_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.SelectTenant, StatusCodes.Status200OK); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); + + sealed class FakeTenantsHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """[{"id":"tenant-a","name":"Tenant A"},{"id":"tenant-b","name":"Tenant B"}]""", + Encoding.UTF8, + "application/json") + }); + } +} diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_request_is_authentication_bootstrap.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_request_is_authentication_bootstrap.cs new file mode 100644 index 0000000..a23cdd2 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_request_is_authentication_bootstrap.cs @@ -0,0 +1,50 @@ +// 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.for_TenantSelectionMiddleware; + +public class when_request_is_authentication_bootstrap : Specification +{ + TenantSelectionMiddleware _middleware; + DefaultHttpContext _context; + 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>(); + config.CurrentValue.Returns(authProxyConfig); + + _middleware = new TenantSelectionMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + config, + Substitute.For(), + Substitute.For(), + Substitute.For()); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/signin-scheme"; + _context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("oid", "user-id")], "aad")); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_call_next() => _nextCalled.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_selecting_a_tenant.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_selecting_a_tenant.cs new file mode 100644 index 0000000..54f0a90 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_selecting_a_tenant.cs @@ -0,0 +1,75 @@ +// 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_selecting_a_tenant : Specification +{ + TenantSelectionMiddleware _middleware; + DefaultHttpContext _context; + 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>(); + config.CurrentValue.Returns(authProxyConfig); + + var tenantResolver = Substitute.For(); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(new HttpClient(new FakeTenantsHandler())); + + _middleware = new TenantSelectionMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + config, + tenantResolver, + httpClientFactory, + Substitute.For()); + + _context = new DefaultHttpContext(); + _context.Request.Path = WellKnownPaths.SelectTenant; + _context.Request.QueryString = new QueryString("?tenantId=tenant-b&returnUrl=%2Fdashboard"); + _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_selected_tenant_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Tenant}=tenant-b"); + [Fact] void should_redirect_to_return_url() => _context.Response.Headers.Location.ToString().ShouldEqual("/dashboard"); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); + + sealed class FakeTenantsHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """[{"id":"tenant-a","name":"Tenant A"},{"id":"tenant-b","name":"Tenant B"}]""", + Encoding.UTF8, + "application/json") + }); + } +} diff --git a/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs b/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs index fd7e6bb..6abc9d5 100644 --- a/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs +++ b/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs @@ -28,4 +28,7 @@ public enum TenantSourceIdentifierResolverType /// Resolve the tenant ID from the request subhost by convention (for example, tenant.example.com -> tenant). SubHost = 6, + + /// Resolve the tenant ID from the selected-tenant cookie set via the tenant-selection page. + Selection = 7, } diff --git a/Source/AuthProxy/Cookies.cs b/Source/AuthProxy/Cookies.cs index e00c3cf..7bb0d72 100644 --- a/Source/AuthProxy/Cookies.cs +++ b/Source/AuthProxy/Cookies.cs @@ -25,4 +25,17 @@ public static class Cookies /// This cookie is intentionally not HTTP-only so that client-side script can read it. /// public const string Providers = ".cratis-providers"; + + /// + /// Short-lived cookie injected by the proxy when serving the tenant-selection page. + /// Contains a JSON array of tenant options (id and name) so the page can render + /// without calling the tenant endpoint directly. + /// This cookie is intentionally not HTTP-only so that client-side script can read it. + /// + public const string Tenants = ".cratis-tenants"; + + /// + /// Cookie that stores the selected tenant identifier for subsequent requests. + /// + public const string Tenant = ".cratis-tenant"; } diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 7a60287..c95aca8 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -70,6 +70,7 @@ public static WebApplication UseIngress(this WebApplication app) app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); + app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); diff --git a/Source/AuthProxy/Pages/select-tenant.html b/Source/AuthProxy/Pages/select-tenant.html new file mode 100644 index 0000000..7ad8c84 --- /dev/null +++ b/Source/AuthProxy/Pages/select-tenant.html @@ -0,0 +1,68 @@ + + + + + + Select Tenant + + + +
    +

    Select tenant

    +

    Choose which tenant you want to continue with:

    +
    + +
    + + + diff --git a/Source/AuthProxy/Tenancy/SelectionOptions.cs b/Source/AuthProxy/Tenancy/SelectionOptions.cs new file mode 100644 index 0000000..e425f9a --- /dev/null +++ b/Source/AuthProxy/Tenancy/SelectionOptions.cs @@ -0,0 +1,15 @@ +// 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; + +/// +/// Options for the . +/// +public record SelectionOptions +{ + /// + /// Gets the endpoint URL that returns the selectable tenant list for the authenticated user. + /// + public string TenantsEndpoint { get; init; } = string.Empty; +} diff --git a/Source/AuthProxy/Tenancy/SelectionSourceIdentifierStrategy.cs b/Source/AuthProxy/Tenancy/SelectionSourceIdentifierStrategy.cs new file mode 100644 index 0000000..08fa666 --- /dev/null +++ b/Source/AuthProxy/Tenancy/SelectionSourceIdentifierStrategy.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Tenancy; + +/// +/// Resolves the tenant directly from the selected-tenant cookie. +/// +public class SelectionSourceIdentifierStrategy : ISourceIdentifierStrategyTyped +{ + /// + public C.TenantSourceIdentifierResolverType Type => C.TenantSourceIdentifierResolverType.Selection; + + /// + public bool TryResolveSourceIdentifier(HttpContext context, SelectionOptions typedOptions, out string sourceIdentifier) + { + sourceIdentifier = string.Empty; + + if (!context.Request.Cookies.TryGetValue(Cookies.Tenant, out var tenantId) + || string.IsNullOrWhiteSpace(tenantId)) + { + return false; + } + + sourceIdentifier = tenantId; + return true; + } +} diff --git a/Source/AuthProxy/Tenancy/TenancyServiceCollectionExtensions.cs b/Source/AuthProxy/Tenancy/TenancyServiceCollectionExtensions.cs index 6a1ac31..5a94b69 100644 --- a/Source/AuthProxy/Tenancy/TenancyServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Tenancy/TenancyServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static WebApplicationBuilder AddTenancy(this WebApplicationBuilder builde builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton, TenantResolutionOptionsConfigurer>(); diff --git a/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs b/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs index 3ff67c9..d6ef099 100644 --- a/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs +++ b/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs @@ -36,6 +36,7 @@ public void PostConfigure(string? name, C.AuthProxy options) C.TenantSourceIdentifierResolverType.Specified => optionsSection.Get() ?? new SpecifiedOptions(), C.TenantSourceIdentifierResolverType.Default => optionsSection.Get() ?? new DefaultOptions(), C.TenantSourceIdentifierResolverType.SubHost => optionsSection.Get() ?? new SubHostOptions(), + C.TenantSourceIdentifierResolverType.Selection => optionsSection.Get() ?? new SelectionOptions(), _ => null }; } diff --git a/Source/AuthProxy/TenantResolver.cs b/Source/AuthProxy/TenantResolver.cs index d498f37..f9f8d0b 100644 --- a/Source/AuthProxy/TenantResolver.cs +++ b/Source/AuthProxy/TenantResolver.cs @@ -103,6 +103,21 @@ public bool TryResolve(HttpContext context, out TenantResolutionResult result) return true; } } + else if (strategy is T.ISourceIdentifierStrategyTyped selectionStrategy) + { + var selectionOptions = resolution.Options as T.SelectionOptions ?? new T.SelectionOptions(); + + if (!selectionStrategy.TryResolveSourceIdentifier(context, selectionOptions, out var sourceIdentifier)) + { + continue; + } + + if (HandleResolvedSourceIdentifier(resolution.Strategy, sourceIdentifier, out tenantId, config.CurrentValue)) + { + result = new TenantResolutionResult(tenantId, resolution.Strategy); + return true; + } + } else if (strategy is T.ISourceIdentifierStrategyTyped routeStrategy) { var routeOptions = resolution.Options as T.RouteOptions ?? new T.RouteOptions(); @@ -167,7 +182,7 @@ private static bool HandleResolvedSourceIdentifier( tenantId = string.Empty; // Specified, Default, and SubHost strategies return a fixed tenant ID directly. - if (strategyType == Type.Specified || strategyType == Type.Default || strategyType == Type.SubHost) + if (strategyType == Type.Specified || strategyType == Type.Default || strategyType == Type.SubHost || strategyType == Type.Selection) { if (!string.IsNullOrWhiteSpace(sourceIdentifier)) { diff --git a/Source/AuthProxy/TenantSelectionMiddleware.cs b/Source/AuthProxy/TenantSelectionMiddleware.cs new file mode 100644 index 0000000..ed97179 --- /dev/null +++ b/Source/AuthProxy/TenantSelectionMiddleware.cs @@ -0,0 +1,195 @@ +// 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.ErrorPages; +using Cratis.AuthProxy.Identity; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy; + +/// +/// Middleware that handles tenant selection for authenticated users when the selection strategy is configured. +/// +/// The next middleware in the pipeline. +/// The auth proxy configuration monitor. +/// The tenant resolver. +/// The HTTP client factory. +/// The error page provider used to serve the selection page. +public class TenantSelectionMiddleware( + RequestDelegate next, + IOptionsMonitor config, + ITenantResolver tenantResolver, + IHttpClientFactory httpClientFactory, + IErrorPageProvider errorPageProvider) +{ + static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + /// + public async Task InvokeAsync(HttpContext context) + { + if (context.User.Identity?.IsAuthenticated != true + || context.IsInvitation() + || context.IsAuthenticationBootstrap() + || context.HasPendingInvitation()) + { + await next(context); + return; + } + + if (!TryGetSelectionOptions(config.CurrentValue, out var selectionOptions)) + { + await next(context); + return; + } + + if (context.Request.Path.StartsWithSegments(WellKnownPaths.SelectTenant)) + { + await HandleTenantSelection(context, selectionOptions); + return; + } + + if (tenantResolver.TryResolve(context, out string _)) + { + await next(context); + return; + } + + var tenantOptions = await GetTenantOptions(context, selectionOptions); + if (tenantOptions.Count == 0) + { + await next(context); + return; + } + + var tenantsJson = JsonSerializer.Serialize(tenantOptions, _serializerOptions); + context.Response.Cookies.Append(Cookies.Tenants, tenantsJson, new CookieOptions + { + HttpOnly = false, + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps, + MaxAge = TimeSpan.FromMinutes(15), + }); + + await errorPageProvider.WriteErrorPageAsync( + context, + WellKnownPageNames.SelectTenant, + StatusCodes.Status200OK); + } + + async Task HandleTenantSelection(HttpContext context, Tenancy.SelectionOptions selectionOptions) + { + var tenantId = context.Request.Query["tenantId"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var tenantOptions = await GetTenantOptions(context, selectionOptions); + if (!tenantOptions.Any(_ => string.Equals(_.Id, tenantId, StringComparison.OrdinalIgnoreCase))) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + context.Response.Cookies.Append(Cookies.Tenant, tenantId, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps, + }); + + context.Response.Cookies.Delete(Cookies.Tenants); + + var requestedReturnUrl = context.Request.Query["returnUrl"].FirstOrDefault(); + var returnUrl = IsSafeRelativeUrl(requestedReturnUrl) ? requestedReturnUrl! : "/"; + context.Response.Redirect(returnUrl); + } + + bool IsSafeRelativeUrl(string? url) => + !string.IsNullOrWhiteSpace(url) + && Uri.TryCreate(url, UriKind.Relative, out _) + && url.StartsWith('/'); + + bool TryGetSelectionOptions(C.AuthProxy authProxyConfig, out Tenancy.SelectionOptions selectionOptions) + { + selectionOptions = new(); + var selectionResolution = authProxyConfig.TenantResolutions + .FirstOrDefault(_ => _.Strategy == C.TenantSourceIdentifierResolverType.Selection); + if (selectionResolution?.Options is not Tenancy.SelectionOptions typedSelectionOptions) + { + return false; + } + + if (string.IsNullOrWhiteSpace(typedSelectionOptions.TenantsEndpoint) + || !Uri.IsWellFormedUriString(typedSelectionOptions.TenantsEndpoint, UriKind.Absolute)) + { + return false; + } + + selectionOptions = typedSelectionOptions; + return true; + } + + async Task> GetTenantOptions(HttpContext context, Tenancy.SelectionOptions selectionOptions) + { + var principal = context.BuildClientPrincipal(); + if (principal is null) + { + return []; + } + + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, selectionOptions.TenantsEndpoint); + request.SetMicrosoftIdentityHeaders(principal); + + HttpResponseMessage response; + try + { + response = await client.SendAsync(request); + } + catch (Exception) + { + return []; + } + + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden || response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return []; + } + + if (!response.IsSuccessStatusCode) + { + return []; + } + + var json = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + var tenants = JsonSerializer.Deserialize>(json, _serializerOptions) ?? []; + return tenants + .Where(_ => !string.IsNullOrWhiteSpace(_.Id) && !string.IsNullOrWhiteSpace(_.Name)) + .DistinctBy(_ => _.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception) + { + return []; + } + } + + sealed record TenantOption( + [property: System.Text.Json.Serialization.JsonPropertyName("id")] string Id, + [property: System.Text.Json.Serialization.JsonPropertyName("name")] string Name); +} diff --git a/Source/AuthProxy/WellKnownPageNames.cs b/Source/AuthProxy/WellKnownPageNames.cs index ad8c47c..dc9d107 100644 --- a/Source/AuthProxy/WellKnownPageNames.cs +++ b/Source/AuthProxy/WellKnownPageNames.cs @@ -53,6 +53,12 @@ public static class WellKnownPageNames /// public const string SelectProvider = "select-provider.html"; + /// + /// The page served when tenant selection is enabled and the authenticated user has no selected tenant. + /// The page reads the .cratis-tenants cookie injected by the proxy and renders a selectable list. + /// + public const string SelectTenant = "select-tenant.html"; + /// /// The page served when an unauthenticated user reaches a lobby-mode proxy without a /// valid invitation token or pending invite cookie. diff --git a/Source/AuthProxy/WellKnownPaths.cs b/Source/AuthProxy/WellKnownPaths.cs index 3b2b01d..30fa933 100644 --- a/Source/AuthProxy/WellKnownPaths.cs +++ b/Source/AuthProxy/WellKnownPaths.cs @@ -31,6 +31,11 @@ public static class WellKnownPaths /// public const string LoginPage = "/.cratis/select-provider"; + /// + /// The well-known path used by the tenant-selection page to submit the chosen tenant. + /// + public const string SelectTenant = "/.cratis/select-tenant"; + /// /// The well-known path prefix that triggers invite-token handling. /// Append the token to complete the URL (e.g. /invite/<token>). @@ -43,4 +48,3 @@ public static class WellKnownPaths /// public const string Pages = "/_pages"; } - From dc74ba584cba66e11f14ac5a4d1dde0d1a303bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 04:23:30 +0000 Subject: [PATCH 3/4] Harden tenant selection redirect handling Agent-Logs-Url: https://github.com/Cratis/AuthProxy/sessions/8002ad56-7619-4213-b489-6d41e3194b1c Co-authored-by: einari <134365+einari@users.noreply.github.com> --- Source/AuthProxy/TenantSelectionMiddleware.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Source/AuthProxy/TenantSelectionMiddleware.cs b/Source/AuthProxy/TenantSelectionMiddleware.cs index ed97179..5b5a974 100644 --- a/Source/AuthProxy/TenantSelectionMiddleware.cs +++ b/Source/AuthProxy/TenantSelectionMiddleware.cs @@ -107,8 +107,15 @@ async Task HandleTenantSelection(HttpContext context, Tenancy.SelectionOptions s context.Response.Cookies.Delete(Cookies.Tenants); var requestedReturnUrl = context.Request.Query["returnUrl"].FirstOrDefault(); - var returnUrl = IsSafeRelativeUrl(requestedReturnUrl) ? requestedReturnUrl! : "/"; - context.Response.Redirect(returnUrl); + if (!IsSafeRelativeUrl(requestedReturnUrl)) + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = "/"; + return; + } + + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = requestedReturnUrl; } bool IsSafeRelativeUrl(string? url) => From f5e73a399635df5dfb09e072b363a81075f0027b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 05:28:05 +0000 Subject: [PATCH 4/4] Auto-select single tenant and skip selector page Agent-Logs-Url: https://github.com/Cratis/AuthProxy/sessions/3e4b20fb-3d5c-4e88-9d38-0f9158bf791f Co-authored-by: einari <134365+einari@users.noreply.github.com> --- .../configuration/tenant-selection.md | 11 +-- ...tenant_and_only_one_tenant_is_available.cs | 83 +++++++++++++++++++ Source/AuthProxy/TenantSelectionMiddleware.cs | 14 ++++ 3 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_only_one_tenant_is_available.cs diff --git a/Documentation/configuration/tenant-selection.md b/Documentation/configuration/tenant-selection.md index 0901db0..3eaf8ba 100644 --- a/Documentation/configuration/tenant-selection.md +++ b/Documentation/configuration/tenant-selection.md @@ -11,11 +11,12 @@ The page receives tenant data through a cookie, not by calling your tenant endpo 1. Authenticated request arrives without a `.cratis-tenant` cookie. 2. AuthProxy calls the configured `Selection.Options.TenantsEndpoint`. -3. AuthProxy writes `.cratis-tenants` (URL-encoded JSON array) and serves `select-tenant.html`. -4. User clicks a tenant option. -5. Browser navigates to `/.cratis/select-tenant?tenantId=&returnUrl=`. -6. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`. -7. AuthProxy redirects back to `returnUrl`. +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=&returnUrl=`. +7. AuthProxy validates the selected `tenantId` against `TenantsEndpoint` and sets `.cratis-tenant`. +8. AuthProxy redirects back to `returnUrl`. --- diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_only_one_tenant_is_available.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_only_one_tenant_is_available.cs new file mode 100644 index 0000000..b15a6e9 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_authenticated_user_has_no_resolved_tenant_and_only_one_tenant_is_available.cs @@ -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>(); + config.CurrentValue.Returns(authProxyConfig); + + var tenantResolver = Substitute.For(); + tenantResolver.TryResolve(Arg.Any(), out Arg.Any()).Returns(false); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(new HttpClient(new FakeSingleTenantHandler())); + + _errorPageProvider = Substitute.For(); + _errorPageProvider + .WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any(), Arg.Any()); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); + + sealed class FakeSingleTenantHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """[{"id":"tenant-a","name":"Tenant A"}]""", + Encoding.UTF8, + "application/json") + }); + } +} diff --git a/Source/AuthProxy/TenantSelectionMiddleware.cs b/Source/AuthProxy/TenantSelectionMiddleware.cs index 5b5a974..1a324e8 100644 --- a/Source/AuthProxy/TenantSelectionMiddleware.cs +++ b/Source/AuthProxy/TenantSelectionMiddleware.cs @@ -66,6 +66,20 @@ public async Task InvokeAsync(HttpContext context) return; } + if (tenantOptions.Count == 1) + { + context.Response.Cookies.Append(Cookies.Tenant, tenantOptions[0].Id, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps, + }); + context.Response.Cookies.Delete(Cookies.Tenants); + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = context.GetPathAndQuery(); + return; + } + var tenantsJson = JsonSerializer.Serialize(tenantOptions, _serializerOptions); context.Response.Cookies.Append(Cookies.Tenants, tenantsJson, new CookieOptions {