diff --git a/Documentation/configuration/link.md b/Documentation/configuration/link.md new file mode 100644 index 0000000..0ad9ae8 --- /dev/null +++ b/Documentation/configuration/link.md @@ -0,0 +1,92 @@ +# Credential Linking + +AuthProxy can let an **already signed-in** user prove control of an additional identity-provider login and +associate it with their existing account — without ever replacing their current session. This is the +proof-of-control building block behind an application's "add a credential" feature. + +``` +GET /.cratis/link/{scheme}?returnUrl=&token= +``` + +It is deliberately **not** the same as `/.cratis/login/{scheme}`: login signs the authenticated identity into +the primary session cookie (which, for a second identity, would swap who the user is — effectively logging +them out of the original account). The link flow authenticates the second provider, captures its subject, +and hands that subject to the application, all while leaving the primary session untouched. + +--- + +## Why a popup, not an iframe + +The application opens `/.cratis/link/{scheme}` in a **popup** (or a top-level redirect), never a nested +iframe. Provider consent pages send `X-Frame-Options: DENY`, and AuthProxy's authentication and OAuth +correlation/nonce cookies are `SameSite=Lax`, so a cross-site iframe cannot complete the flow. A same-origin +popup avoids both problems. + +--- + +## How it works + +1. **The application mints a one-time link token.** When the user starts "add a credential", the application + issues a short-lived, single-use token bound to that signed-in user, and opens the popup at + `/.cratis/link/{scheme}?returnUrl=…&token=…`. The `scheme` is a configured provider scheme (the same value + used by `/.cratis/login/{scheme}`, e.g. `github`). +2. **AuthProxy challenges the provider.** The request must come from an authenticated session (an anonymous + request is rejected with `401`; an unknown scheme with `404`; a missing token with `400`). AuthProxy starts + an OAuth/OIDC challenge for the requested scheme, carrying a link-mode marker and the link token through the + authentication properties. +3. **On the provider callback the identity is captured, not signed in.** Instead of writing the primary + authentication cookie, AuthProxy reads the freshly authenticated `subject` (and identity provider) and + `POST`s them to the configured [`ExchangeUrl`](#configuration), authenticated with the link token as the + bearer credential — exactly mirroring the [invite exchange](./lobby/invitation-to-organization.md). The + user's original session is preserved. +4. **AuthProxy returns to the application.** The browser is redirected to the supplied `returnUrl`, where the + application correlates the token back to the signed-in user, records the association, and closes the popup. + +The request body posted to `ExchangeUrl` matches the invite exchange shape: + +```json +{ "subject": "", "identityProvider": "" } +``` + +with `Authorization: Bearer `. + +--- + +## The `returnUrl` parameter + +`returnUrl` is echoed back to the browser after the link completes, so it is constrained to a **same-site +relative path** (a single leading `/`, but not `//`). Anything else — including an absolute URL to another +origin — falls back to the application root (`/`), so the endpoint can never be turned into an open redirect. + +--- + +## Configuration + +Set the application endpoint that records the freshly authenticated subject under +`Cratis:AuthProxy:Link:ExchangeUrl`. It is the link counterpart of `Cratis:AuthProxy:Invite:ExchangeUrl`. + +```json +{ + "Cratis": { + "AuthProxy": { + "Link": { + "ExchangeUrl": "https://studio.example.com/api/internal/identity-providers/link" + } + } + } +} +``` + +Equivalent environment variable: + +``` +Cratis__AuthProxy__Link__ExchangeUrl=https://studio.example.com/api/internal/identity-providers/link +``` + +When `ExchangeUrl` is empty or the `Link` section is absent, the link callback is skipped (the subject is not +posted anywhere) and the flow is effectively disabled. + +> **Security.** The exchange endpoint is a service-to-service back-channel: it is reachable by AuthProxy, not by +> browsers, and it trusts the subject AuthProxy delivers from a real provider authentication together with the +> one-time link token — never a client-supplied subject. Point `ExchangeUrl` at an internal application address +> and keep the token short-lived and single-use, exactly as for the invite exchange. diff --git a/Documentation/configuration/logout.md b/Documentation/configuration/logout.md new file mode 100644 index 0000000..b33a6e5 --- /dev/null +++ b/Documentation/configuration/logout.md @@ -0,0 +1,98 @@ +# Logout + +AuthProxy exposes a well-known logout endpoint that ends the current session and returns the user to a +validated destination: + +``` +GET /.cratis/logout?redirect= +``` + +The endpoint is anonymous — it works even when the session is already invalid — and is handled before +the authentication challenge stages, so it never bounces the user to a login provider. + +--- + +## What it clears + +A logout request: + +1. Signs the user out of the authentication cookie (`.Cratis.AuthProxy.Auth.v2`), including any chunked variants. +2. Deletes every AuthProxy session cookie: + - `.cratis-identity` + - `.cratis-tenant` + - `.cratis-tenants` + - `.cratis-invite` + - `.cratis-registration` + - `.cratis-providers` +3. Redirects (`302 Found`) to the validated `redirect` target. + +--- + +## The `redirect` parameter + +The post-logout destination is supplied as an **absolute URL** in the `redirect` query-string parameter, +for example: + +``` +/.cratis/logout?redirect=https://cratis.studio +``` + +Because the target is absolute, it cannot be validated with the relative-URL check used elsewhere. +Instead it is matched against an **allow-list of origins** so the endpoint can never be turned into an +open redirect. A target is allowed when its origin (scheme + host + port) matches any of: + +- The proxy's **own public origin** as seen by the browser (derived from the request, honoring + `X-Forwarded-Proto`). This covers redirecting back to the site the user is already on. +- Any configured **service frontend** (`Cratis:AuthProxy:Services::Frontend:BaseUrl`). +- The configured **lobby frontend** (`Cratis:AuthProxy:Invite:Lobby:Frontend:BaseUrl`). +- Any origin listed in **`Cratis:AuthProxy:Logout:AllowedRedirectOrigins`** (see below). + +Same-site **relative** URLs (a single leading `/`, but not `//`) are always allowed. + +If `redirect` is missing, empty, or fails validation, AuthProxy falls back to the application root (`/`). + +--- + +## Allowing additional post-logout origins + +The implicit origins above cover redirecting back to the app itself, but a deployment often wants to send +the user somewhere else after logout — for example a separate marketing or landing site that is neither +the proxy's own origin nor a configured frontend. List those origins under +`Cratis:AuthProxy:Logout:AllowedRedirectOrigins`. Each entry is an absolute origin (scheme + host, +optionally a port) with no path; malformed or non-HTTP(S) entries are ignored. + +```json +{ + "Cratis": { + "AuthProxy": { + "Logout": { + "AllowedRedirectOrigins": [ + "https://cratis.studio" + ] + } + } + } +} +``` + +Equivalent environment variables (one indexed key per entry): + +``` +Cratis__AuthProxy__Logout__AllowedRedirectOrigins__0=https://cratis.studio +``` + +With the example above, `GET /.cratis/logout?redirect=https://cratis.studio` is permitted even when the +app itself is served from a different host such as `https://app.cratis.studio`. + +--- + +## Example + +A "Log out" control in the application simply navigates the browser to the endpoint: + +```html +Log out +``` + +After the redirect the user lands on the target unauthenticated; requesting a protected resource then +triggers the normal login flow. diff --git a/Documentation/configuration/tenant-selection.md b/Documentation/configuration/tenant-selection.md index 3eaf8ba..e2a1fd1 100644 --- a/Documentation/configuration/tenant-selection.md +++ b/Documentation/configuration/tenant-selection.md @@ -11,12 +11,34 @@ 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. 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`. +3. If exactly one tenant is returned, AuthProxy sets `.cratis-tenant` immediately, removes any `.cratis-tenants` cookie, 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) as a session cookie 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`. +8. AuthProxy redirects back to `returnUrl`. The `.cratis-tenants` cookie is **retained** so the application can offer an in-app tenant switcher. + +--- + +## Switching tenants after selection + +For a user with **more than one** tenant, `.cratis-tenants` is written as a **session cookie** and is +**not** deleted when a tenant is selected. It therefore remains available for the rest of the browser +session, which lets the application's toolbar decide whether to show a "switch tenant" control (show it +only when the cookie lists more than one tenant). + +To switch, the toolbar navigates to the same selection endpoint used by the selection page: + +``` +/.cratis/select-tenant?tenantId=&returnUrl= +``` + +Every switch re-validates the requested `tenantId` against `TenantsEndpoint`, so a stale cookie can +never grant access to a tenant the user is no longer a member of — an unknown `tenantId` is rejected +with `400 Bad Request`. + +A user with exactly **one** tenant never receives `.cratis-tenants` (it is removed when the single +tenant is auto-selected), so no switcher is shown for single-tenant users. --- diff --git a/Documentation/configuration/toc.yml b/Documentation/configuration/toc.yml index c651e7d..aee44d9 100644 --- a/Documentation/configuration/toc.yml +++ b/Documentation/configuration/toc.yml @@ -6,6 +6,10 @@ href: tenancy.md - name: Tenant Selection Page href: tenant-selection.md +- name: Logout + href: logout.md +- name: Credential Linking + href: link.md - name: Services href: services.md - name: Lobby diff --git a/Source/AuthProxy.Specs/Links/RecordingHttpMessageHandler.cs b/Source/AuthProxy.Specs/Links/RecordingHttpMessageHandler.cs new file mode 100644 index 0000000..637dd29 --- /dev/null +++ b/Source/AuthProxy.Specs/Links/RecordingHttpMessageHandler.cs @@ -0,0 +1,20 @@ +// 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.Links; + +public class RecordingHttpMessageHandler(HttpStatusCode statusCode = HttpStatusCode.OK) : HttpMessageHandler +{ + public HttpRequestMessage? LastRequest { get; private set; } + + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(statusCode); + } +} diff --git a/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/given/a_link_subject_exchanger.cs b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/given/a_link_subject_exchanger.cs new file mode 100644 index 0000000..fbf2c7f --- /dev/null +++ b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/given/a_link_subject_exchanger.cs @@ -0,0 +1,51 @@ +// 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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger.given; + +public class a_link_subject_exchanger : Specification +{ + protected const string ExchangeUrl = "https://studio.example.com/api/internal/identity-providers/link"; + protected const string LinkToken = "the-one-time-link-token"; + + protected LinkSubjectExchanger _exchanger; + protected RecordingHttpMessageHandler _handler; + protected IOptionsMonitor _config; + protected ClaimsPrincipal _principal; + protected AuthenticationProperties _properties; + + protected virtual C.AuthProxy CreateConfig() => new() { Link = new C.Link { ExchangeUrl = ExchangeUrl } }; + + protected virtual HttpStatusCode ExchangeStatusCode => HttpStatusCode.OK; + + protected virtual AuthenticationProperties CreateProperties() + { + var properties = new AuthenticationProperties(); + properties.Items[LinkMiddleware.LinkTokenPropertyKey] = LinkToken; + return properties; + } + + void Establish() + { + _config = Substitute.For>(); + _config.CurrentValue.Returns(CreateConfig()); + + _handler = new RecordingHttpMessageHandler(ExchangeStatusCode); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(_handler)); + + _principal = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "linked-subject-123"), + new Claim("iss", "https://github.com"), + ], + "github")); + + _properties = CreateProperties(); + + _exchanger = new LinkSubjectExchanger(_config, httpClientFactory, Substitute.For>()); + } +} diff --git a/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_exchanging_a_subject.cs b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_exchanging_a_subject.cs new file mode 100644 index 0000000..2506108 --- /dev/null +++ b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_exchanging_a_subject.cs @@ -0,0 +1,21 @@ +// 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.Links.for_LinkSubjectExchanger.given; + +namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger; + +public class when_exchanging_a_subject : a_link_subject_exchanger +{ + LinkExchangeResult _result; + + async Task Because() => _result = await _exchanger.Exchange(_principal, _properties); + + [Fact] void should_succeed() => _result.ShouldEqual(LinkExchangeResult.Success); + [Fact] void should_post_to_the_configured_exchange_url() => _handler.LastRequest!.RequestUri!.ToString().ShouldEqual(ExchangeUrl); + [Fact] void should_post() => _handler.LastRequest!.Method.ShouldEqual(HttpMethod.Post); + [Fact] void should_authenticate_with_the_link_token() => _handler.LastRequest!.Headers.Authorization!.Parameter.ShouldEqual(LinkToken); + [Fact] void should_use_the_bearer_scheme() => _handler.LastRequest!.Headers.Authorization!.Scheme.ShouldEqual("Bearer"); + [Fact] void should_send_the_linked_subject() => _handler.LastRequestBody!.ShouldContain("linked-subject-123"); + [Fact] void should_send_the_identity_provider() => _handler.LastRequestBody!.ShouldContain("https://github.com"); +} diff --git a/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_endpoint_returns_an_error.cs b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_endpoint_returns_an_error.cs new file mode 100644 index 0000000..e674132 --- /dev/null +++ b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_endpoint_returns_an_error.cs @@ -0,0 +1,18 @@ +// 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 Cratis.AuthProxy.Links.for_LinkSubjectExchanger.given; + +namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger; + +public class when_the_endpoint_returns_an_error : a_link_subject_exchanger +{ + LinkExchangeResult _result; + + protected override HttpStatusCode ExchangeStatusCode => HttpStatusCode.InternalServerError; + + async Task Because() => _result = await _exchanger.Exchange(_principal, _properties); + + [Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed); +} diff --git a/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_exchange_url_is_not_configured.cs b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_exchange_url_is_not_configured.cs new file mode 100644 index 0000000..64910e7 --- /dev/null +++ b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_exchange_url_is_not_configured.cs @@ -0,0 +1,18 @@ +// 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.Links.for_LinkSubjectExchanger.given; + +namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger; + +public class when_the_exchange_url_is_not_configured : a_link_subject_exchanger +{ + LinkExchangeResult _result; + + protected override C.AuthProxy CreateConfig() => new() { Link = null }; + + async Task Because() => _result = await _exchanger.Exchange(_principal, _properties); + + [Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed); + [Fact] void should_not_post_anything() => _handler.LastRequest.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_link_token_is_missing.cs b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_link_token_is_missing.cs new file mode 100644 index 0000000..3ae6815 --- /dev/null +++ b/Source/AuthProxy.Specs/Links/for_LinkSubjectExchanger/when_the_link_token_is_missing.cs @@ -0,0 +1,19 @@ +// 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.Links.for_LinkSubjectExchanger.given; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Links.for_LinkSubjectExchanger; + +public class when_the_link_token_is_missing : a_link_subject_exchanger +{ + LinkExchangeResult _result; + + protected override AuthenticationProperties CreateProperties() => new(); + + async Task Because() => _result = await _exchanger.Exchange(_principal, _properties); + + [Fact] void should_fail() => _result.ShouldEqual(LinkExchangeResult.Failed); + [Fact] void should_not_post_anything() => _handler.LastRequest.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_initiating_a_link.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_initiating_a_link.cs new file mode 100644 index 0000000..b2096fc --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_initiating_a_link.cs @@ -0,0 +1,74 @@ +// 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.Links; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_initiating_a_link : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication + { + OAuthProviders = [new C.OAuthProvider { Name = "GitHub" }], + }); + + _middleware = new LinkMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + authConfig, + Substitute.For>()); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity("test")), + }; + _context.Request.Path = $"{WellKnownPaths.Link}/github"; + _context.Request.QueryString = QueryString.Create(new Dictionary + { + ["returnUrl"] = "/settings/link-complete", + ["token"] = "the-link-token", + }); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_challenge_the_requested_scheme() => + _authenticationService.Received(1).ChallengeAsync(_context, "github", Arg.Any()); + + [Fact] void should_mark_the_challenge_as_link_mode() => + _authenticationService.Received(1).ChallengeAsync( + _context, + "github", + Arg.Is(properties => properties.Items[LinkMiddleware.LinkModePropertyKey] == "true")); + + [Fact] void should_carry_the_link_token_on_the_challenge() => + _authenticationService.Received(1).ChallengeAsync( + _context, + "github", + Arg.Is(properties => properties.Items[LinkMiddleware.LinkTokenPropertyKey] == "the-link-token")); + + [Fact] void should_return_to_the_requested_relative_url() => + _authenticationService.Received(1).ChallengeAsync( + _context, + "github", + Arg.Is(properties => properties.RedirectUri == "/settings/link-complete")); + + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_link_token_is_missing.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_link_token_is_missing.cs new file mode 100644 index 0000000..4861c93 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_link_token_is_missing.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. + +using Cratis.AuthProxy.Links; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_the_link_token_is_missing : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication + { + OAuthProviders = [new C.OAuthProvider { Name = "GitHub" }], + }); + + _middleware = new LinkMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + authConfig, + Substitute.For>()); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity("test")), + }; + _context.Request.Path = $"{WellKnownPaths.Link}/github"; + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_respond_with_bad_request() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status400BadRequest); + [Fact] void should_not_challenge() => _authenticationService.DidNotReceive().ChallengeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_provider_is_not_configured.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_provider_is_not_configured.cs new file mode 100644 index 0000000..4f337af --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_provider_is_not_configured.cs @@ -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 Cratis.AuthProxy.Links; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_the_provider_is_not_configured : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication()); + + _middleware = new LinkMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + authConfig, + Substitute.For>()); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity("test")), + }; + _context.Request.Path = $"{WellKnownPaths.Link}/unknown"; + _context.Request.QueryString = QueryString.Create("token", "the-link-token"); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_respond_with_not_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status404NotFound); + [Fact] void should_not_challenge() => _authenticationService.DidNotReceive().ChallengeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_request_is_not_a_link.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_request_is_not_a_link.cs new file mode 100644 index 0000000..a17cdc6 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_request_is_not_a_link.cs @@ -0,0 +1,35 @@ +// 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.Links; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_the_request_is_not_a_link : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + bool _nextCalled; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication()); + + _middleware = new LinkMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + authConfig, + Substitute.For>()); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/some/application/path"; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_call_next() => _nextCalled.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_return_url_is_not_relative.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_return_url_is_not_relative.cs new file mode 100644 index 0000000..6a7726e --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_return_url_is_not_relative.cs @@ -0,0 +1,52 @@ +// 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.Links; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_the_return_url_is_not_relative : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication + { + OAuthProviders = [new C.OAuthProvider { Name = "GitHub" }], + }); + + _middleware = new LinkMiddleware( + _ => Task.CompletedTask, + authConfig, + Substitute.For>()); + + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity("test")), + }; + _context.Request.Path = $"{WellKnownPaths.Link}/github"; + _context.Request.QueryString = QueryString.Create(new Dictionary + { + ["returnUrl"] = "https://evil.example.com/steal", + ["token"] = "the-link-token", + }); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_fall_back_to_the_application_root() => + _authenticationService.Received(1).ChallengeAsync( + _context, + "github", + Arg.Is(properties => properties.RedirectUri == "/")); +} diff --git a/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_user_is_not_authenticated.cs b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_user_is_not_authenticated.cs new file mode 100644 index 0000000..14b4786 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LinkMiddleware/when_the_user_is_not_authenticated.cs @@ -0,0 +1,52 @@ +// 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.Links; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LinkMiddleware; + +public class when_the_user_is_not_authenticated : Specification +{ + LinkMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var authConfig = Substitute.For>(); + authConfig.CurrentValue.Returns(new C.Authentication + { + OAuthProviders = [new C.OAuthProvider { Name = "GitHub" }], + }); + + _middleware = new LinkMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + authConfig, + Substitute.For>()); + + // No authentication ticket - an anonymous ClaimsIdentity is not authenticated. + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity()), + }; + _context.Request.Path = $"{WellKnownPaths.Link}/github"; + _context.Request.QueryString = QueryString.Create("token", "the-link-token"); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_respond_with_unauthorized() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status401Unauthorized); + [Fact] void should_not_challenge() => _authenticationService.DidNotReceive().ChallengeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_outside_the_configured_allow_list.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_outside_the_configured_allow_list.cs new file mode 100644 index 0000000..e50948b --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_outside_the_configured_allow_list.cs @@ -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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_redirecting_outside_the_configured_allow_list : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + + void Establish() + { + var authProxyConfig = new C.AuthProxy + { + Logout = new C.Logout + { + AllowedRedirectOrigins = ["https://cratis.studio"] + } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(authProxyConfig); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("app.cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Request.QueryString = QueryString.Create("redirect", "https://evil.example.com/steal"); + _context.Response.Body = new MemoryStream(); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(Substitute.For()); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_fall_back_to_the_application_root() => _context.Response.Headers.Location.ToString().ShouldEqual("/"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_allowed_origin.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_allowed_origin.cs new file mode 100644 index 0000000..1d16152 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_allowed_origin.cs @@ -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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_redirecting_to_a_configured_allowed_origin : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + + void Establish() + { + var authProxyConfig = new C.AuthProxy + { + Logout = new C.Logout + { + AllowedRedirectOrigins = ["https://cratis.studio"] + } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(authProxyConfig); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("app.cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Request.QueryString = QueryString.Create("redirect", "https://cratis.studio"); + _context.Response.Body = new MemoryStream(); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(Substitute.For()); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_redirect_to_the_configured_origin() => _context.Response.Headers.Location.ToString().ShouldEqual("https://cratis.studio"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_frontend.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_frontend.cs new file mode 100644 index 0000000..8a6dbd0 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_redirecting_to_a_configured_frontend.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_redirecting_to_a_configured_frontend : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + + void Establish() + { + var authProxyConfig = new C.AuthProxy + { + Services = new Dictionary + { + ["app"] = new C.Service + { + Frontend = new C.ServiceEndpoint { BaseUrl = "https://app.example.com/" } + } + } + }; + var config = Substitute.For>(); + config.CurrentValue.Returns(authProxyConfig); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("internal-proxy"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Request.QueryString = QueryString.Create("redirect", "https://app.example.com/goodbye"); + _context.Response.Body = new MemoryStream(); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(Substitute.For()); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_redirect_to_the_configured_frontend() => _context.Response.Headers.Location.ToString().ShouldEqual("https://app.example.com/goodbye"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_disallowed_redirect.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_disallowed_redirect.cs new file mode 100644 index 0000000..c93d1ce --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_disallowed_redirect.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_with_a_disallowed_redirect : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + + void Establish() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Request.QueryString = QueryString.Create("redirect", "https://evil.example.com/steal"); + _context.Response.Body = new MemoryStream(); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_still_sign_the_user_out() => _authenticationService.Received(1).SignOutAsync(_context, Arg.Any(), Arg.Any()); + [Fact] void should_still_clear_the_session_cookies() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Identity}=;"); + [Fact] void should_fall_back_to_the_application_root() => _context.Response.Headers.Location.ToString().ShouldEqual("/"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_valid_redirect.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_valid_redirect.cs new file mode 100644 index 0000000..6e52786 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_with_a_valid_redirect.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_with_a_valid_redirect : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + + _middleware = new LogoutMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Request.QueryString = QueryString.Create("redirect", "https://cratis.studio"); + _context.Response.Body = new MemoryStream(); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_sign_out_of_the_authentication_cookie() => _authenticationService.Received(1).SignOutAsync(_context, CookieAuthenticationDefaults.AuthenticationScheme, Arg.Any()); + [Fact] void should_clear_the_identity_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Identity}=;"); + [Fact] void should_clear_the_selected_tenant_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Tenant}=;"); + [Fact] void should_clear_the_tenant_list_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Tenants}=;"); + [Fact] void should_clear_the_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.InviteToken}=;"); + [Fact] void should_clear_the_registration_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Registration}=;"); + [Fact] void should_clear_the_providers_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Providers}=;"); + [Fact] void should_redirect_to_the_requested_target() => _context.Response.Headers.Location.ToString().ShouldEqual("https://cratis.studio"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_without_a_redirect.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_without_a_redirect.cs new file mode 100644 index 0000000..3352ad7 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_logging_out_without_a_redirect.cs @@ -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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_logging_out_without_a_redirect : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + + void Establish() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + + _middleware = new LogoutMiddleware(_ => Task.CompletedTask, config); + + _context = new DefaultHttpContext(); + _context.Request.Scheme = "https"; + _context.Request.Host = new HostString("cratis.studio"); + _context.Request.Path = WellKnownPaths.Logout; + _context.Response.Body = new MemoryStream(); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(Substitute.For()); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_fall_back_to_the_application_root() => _context.Response.Headers.Location.ToString().ShouldEqual("/"); + [Fact] void should_respond_with_found() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status302Found); + [Fact] void should_clear_the_session_cookies() => _context.Response.Headers.SetCookie.ToString().ShouldContain($"{Cookies.Tenant}=;"); +} diff --git a/Source/AuthProxy.Specs/for_LogoutMiddleware/when_request_is_not_a_logout.cs b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_request_is_not_a_logout.cs new file mode 100644 index 0000000..ff0a649 --- /dev/null +++ b/Source/AuthProxy.Specs/for_LogoutMiddleware/when_request_is_not_a_logout.cs @@ -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 Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.for_LogoutMiddleware; + +public class when_request_is_not_a_logout : Specification +{ + LogoutMiddleware _middleware; + DefaultHttpContext _context; + IAuthenticationService _authenticationService; + bool _nextCalled; + + void Establish() + { + var config = Substitute.For>(); + config.CurrentValue.Returns(new C.AuthProxy()); + + _middleware = new LogoutMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + config); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/products"; + _context.Response.Body = new MemoryStream(); + + _authenticationService = Substitute.For(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IAuthenticationService)).Returns(_authenticationService); + _context.RequestServices = serviceProvider; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_call_next() => _nextCalled.ShouldBeTrue(); + [Fact] void should_not_sign_out() => _authenticationService.DidNotReceive().SignOutAsync(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_clear_any_cookies() => _context.Response.Headers.SetCookie.ToString().ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_switching_tenant_retains_the_tenant_list.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_switching_tenant_retains_the_tenant_list.cs new file mode 100644 index 0000000..b9fce15 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_switching_tenant_retains_the_tenant_list.cs @@ -0,0 +1,69 @@ +// 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_switching_tenant_retains_the_tenant_list : Specification +{ + TenantSelectionMiddleware _middleware; + DefaultHttpContext _context; + + 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 httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(new HttpClient(new FakeTenantsHandler())); + + _middleware = new TenantSelectionMiddleware( + _ => Task.CompletedTask, + config, + Substitute.For(), + httpClientFactory, + Substitute.For()); + + _context = new DefaultHttpContext(); + _context.Request.Path = WellKnownPaths.SelectTenant; + _context.Request.QueryString = new QueryString("?tenantId=tenant-b&returnUrl=%2Fdashboard"); + _context.Request.Headers.Cookie = $"{Cookies.Tenants}=%5B%7B%22id%22%3A%22tenant-a%22%7D%5D"; + _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_not_clear_the_tenant_list_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldNotContain($"{Cookies.Tenants}=;"); + [Fact] void should_redirect_to_return_url() => _context.Response.Headers.Location.ToString().ShouldEqual("/dashboard"); + + 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_switching_to_a_tenant_that_is_not_a_member.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_switching_to_a_tenant_that_is_not_a_member.cs new file mode 100644 index 0000000..f8e1644 --- /dev/null +++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_switching_to_a_tenant_that_is_not_a_member.cs @@ -0,0 +1,67 @@ +// 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_switching_to_a_tenant_that_is_not_a_member : Specification +{ + TenantSelectionMiddleware _middleware; + DefaultHttpContext _context; + + 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 httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) + .Returns(new HttpClient(new FakeTenantsHandler())); + + _middleware = new TenantSelectionMiddleware( + _ => Task.CompletedTask, + config, + Substitute.For(), + httpClientFactory, + Substitute.For()); + + _context = new DefaultHttpContext(); + _context.Request.Path = WellKnownPaths.SelectTenant; + _context.Request.QueryString = new QueryString("?tenantId=tenant-c&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_reject_the_selection() => _context.Response.StatusCode.ShouldEqual(StatusCodes.Status400BadRequest); + [Fact] void should_not_set_the_selected_tenant_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldNotContain(Cookies.Tenant); + + 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/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs index b03e7e4..a841da7 100644 --- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Net.Http.Headers; +using Cratis.AuthProxy.Links; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -172,20 +173,7 @@ static void RegisterOidcProviders(AuthenticationBuilder authBuilder, IList - { - if (context.Properties is not null - && TenantAuthenticationState.TryResolvePostAuthenticationRedirectUri( - context.HttpContext, - context.Properties, - context.ReturnUri, - out var redirectUri)) - { - context.ReturnUri = redirectUri; - } - - return Task.CompletedTask; - } + OnTicketReceived = HandleTicketReceived }; }); } @@ -238,22 +226,44 @@ static void RegisterOAuthProviders(AuthenticationBuilder authBuilder, IList - { - if (context.Properties is not null - && TenantAuthenticationState.TryResolvePostAuthenticationRedirectUri( - context.HttpContext, - context.Properties, - context.ReturnUri, - out var redirectUri)) - { - context.ReturnUri = redirectUri; - } - - return Task.CompletedTask; - } + OnTicketReceived = HandleTicketReceived }; }); } } + + /// + /// Shared provider-callback handler. In the session-preserving link flow it captures the freshly + /// authenticated subject and posts it to the application without signing the new identity in, + /// so the user's primary session is preserved; otherwise it applies the tenant post-authentication + /// redirect resolution used by the normal login flow. + /// + /// The ticket-received context. + /// A representing the asynchronous operation. + static async Task HandleTicketReceived(TicketReceivedContext context) + { + if (context.Properties is not null + && context.Properties.Items.TryGetValue(LinkMiddleware.LinkModePropertyKey, out var linkMode) + && linkMode == "true") + { + var exchanger = context.HttpContext.RequestServices.GetRequiredService(); + await exchanger.Exchange(context.Principal, context.Properties); + + // Short-circuit before the RemoteAuthenticationHandler signs the ticket into the cookie scheme: + // the linked identity must never replace the primary session. Hand the browser back to the app. + context.Response.Redirect(context.Properties.RedirectUri ?? "/"); + context.HandleResponse(); + return; + } + + if (context.Properties is not null + && TenantAuthenticationState.TryResolvePostAuthenticationRedirectUri( + context.HttpContext, + context.Properties, + context.ReturnUri, + out var redirectUri)) + { + context.ReturnUri = redirectUri; + } + } } diff --git a/Source/AuthProxy/Configuration/AuthProxy.cs b/Source/AuthProxy/Configuration/AuthProxy.cs index 3a78437..8e2c842 100644 --- a/Source/AuthProxy/Configuration/AuthProxy.cs +++ b/Source/AuthProxy/Configuration/AuthProxy.cs @@ -24,6 +24,17 @@ public class AuthProxy /// public Invite? Invite { get; set; } + /// + /// Gets or sets the credential-linking configuration. + /// Set this section to enable the session-preserving /.cratis/link/{scheme} flow. + /// + public Link? Link { get; set; } + + /// + /// Gets or sets the logout configuration, including the post-logout redirect allow-list. + /// + public Logout Logout { get; set; } = new(); + /// /// Gets or sets the tenant verification configuration. /// When set, the ingress calls the configured service to confirm that a resolved diff --git a/Source/AuthProxy/Configuration/Link.cs b/Source/AuthProxy/Configuration/Link.cs new file mode 100644 index 0000000..66c48b1 --- /dev/null +++ b/Source/AuthProxy/Configuration/Link.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.Configuration; + +/// +/// Represents the configuration for the session-preserving credential-linking flow. +/// +/// +/// The link flow lets an already signed-in user prove control of an additional identity-provider login +/// and associate it with their existing account, without ever replacing their primary session. AuthProxy +/// runs a second OAuth challenge for the requested provider and, on the callback, posts the freshly +/// authenticated subject to the configured instead of signing the new identity +/// in. This mirrors the invite-exchange callback (). +/// +public class Link +{ + /// + /// Gets or sets the absolute URL of the application endpoint that records the freshly authenticated + /// subject for a link, e.g. https://studio.example.com/api/internal/identity-providers/link. + /// AuthProxy posts { subject, identityProvider } to it with the one-time link token supplied by + /// the application as the bearer token, exactly as the invite exchange does. + /// Leave empty to disable the link callback. + /// + public string ExchangeUrl { get; set; } = string.Empty; +} diff --git a/Source/AuthProxy/Configuration/Logout.cs b/Source/AuthProxy/Configuration/Logout.cs new file mode 100644 index 0000000..123b9cd --- /dev/null +++ b/Source/AuthProxy/Configuration/Logout.cs @@ -0,0 +1,18 @@ +// 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.Configuration; + +/// +/// Represents the configuration for the logout endpoint. +/// +public class Logout +{ + /// + /// Gets or sets additional origins permitted as post-logout redirect targets. + /// Each entry is an absolute origin (scheme and host, optionally a port), e.g. https://cratis.studio. + /// These are added to the implicit allow-list — the proxy's own public origin plus the configured + /// service frontends and lobby frontend. Malformed or non-HTTP(S) entries are ignored. + /// + public IList AllowedRedirectOrigins { get; set; } = []; +} diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 9b09c67..52e00f3 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -5,6 +5,7 @@ using Cratis.AuthProxy.ErrorPages; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Invites; +using Cratis.AuthProxy.Links; using Cratis.AuthProxy.Registrations; using Cratis.AuthProxy.ReverseProxy; using Cratis.AuthProxy.Tenancy; @@ -77,7 +78,9 @@ public static WebApplication UseIngress(this WebApplication app) app.Map(WellKnownPaths.Pages, pagesApp => ConfigurePagesPipeline(pagesApp, app.Environment, app.Services.GetRequiredService>())); app.UseStaticFiles(); app.UseAuthentication(); + app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); app.UseAuthorization(); app.UseMiddleware(); app.UseMiddleware(); diff --git a/Source/AuthProxy/Links/ILinkSubjectExchanger.cs b/Source/AuthProxy/Links/ILinkSubjectExchanger.cs new file mode 100644 index 0000000..9989282 --- /dev/null +++ b/Source/AuthProxy/Links/ILinkSubjectExchanger.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. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace Cratis.AuthProxy.Links; + +/// +/// Defines a system that posts the subject of a freshly authenticated link identity to the application's +/// configured link-exchange endpoint, so the application can associate it with the signed-in user. +/// +public interface ILinkSubjectExchanger +{ + /// + /// Posts the authenticated subject and identity provider for a link to the application, authenticated + /// with the one-time link token carried in . + /// + /// The principal produced by the second provider authentication. + /// The round-tripped challenge properties holding the link token. + /// The describing the outcome. + Task Exchange(ClaimsPrincipal? principal, AuthenticationProperties properties); +} diff --git a/Source/AuthProxy/Links/LinkExchangeResult.cs b/Source/AuthProxy/Links/LinkExchangeResult.cs new file mode 100644 index 0000000..2f0d385 --- /dev/null +++ b/Source/AuthProxy/Links/LinkExchangeResult.cs @@ -0,0 +1,16 @@ +// 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.Links; + +/// +/// Represents the outcome of posting a freshly authenticated subject to the application's link-exchange endpoint. +/// +public enum LinkExchangeResult +{ + /// The subject was recorded by the application successfully. + Success = 0, + + /// The exchange could not be performed or was rejected by the application. + Failed = 1, +} diff --git a/Source/AuthProxy/Links/LinkMiddleware.cs b/Source/AuthProxy/Links/LinkMiddleware.cs new file mode 100644 index 0000000..99d5f96 --- /dev/null +++ b/Source/AuthProxy/Links/LinkMiddleware.cs @@ -0,0 +1,114 @@ +// 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.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Links; + +/// +/// Middleware that initiates the session-preserving credential-linking flow. +/// +/// A request to /.cratis/link/{scheme}?returnUrl=…&token=… triggers an OAuth/OIDC challenge for +/// the requested provider — but, unlike the login flow, the resulting authentication does not +/// replace the primary session cookie. Instead the freshly authenticated subject is captured on the +/// provider callback and posted to the application (see +/// OnTicketReceived and +/// ). The link mode marker and the one-time link token travel through +/// the challenge's so the callback can recognize the flow. +/// +/// +/// Linking only makes sense for an already signed-in user, so an unauthenticated request is rejected +/// rather than challenged. The returnUrl is constrained to a same-site relative path so the flow +/// can never be turned into an open redirect. +/// +/// +/// The next middleware in the pipeline. +/// The authentication configuration monitor, used to validate the requested scheme. +/// The logger. +public class LinkMiddleware( + RequestDelegate next, + IOptionsMonitor authConfig, + ILogger logger) +{ + /// + /// The item key marking a challenge as a link (rather than login) flow. + /// + public const string LinkModePropertyKey = "Cratis.AuthProxy.LinkMode"; + + /// + /// The item key carrying the one-time link token through the challenge. + /// + public const string LinkTokenPropertyKey = "Cratis.AuthProxy.LinkToken"; + + const string ApplicationRoot = "/"; + + /// + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments(WellKnownPaths.Link, out var remaining)) + { + await next(context); + return; + } + + var scheme = remaining.Value?.TrimStart('/') ?? string.Empty; + if (string.IsNullOrWhiteSpace(scheme) || !SchemeExists(scheme)) + { + logger.LinkProviderNotConfigured(scheme); + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Linking augments an existing account, so it requires a signed-in user. An anonymous request has + // no primary account to link to — reject it instead of starting a challenge. + if (context.User.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var token = context.Request.Query["token"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var properties = new AuthenticationProperties + { + RedirectUri = ResolveReturnUrl(context.Request.Query["returnUrl"].FirstOrDefault()), + }; + properties.Items[LinkModePropertyKey] = "true"; + properties.Items[LinkTokenPropertyKey] = token; + + logger.InitiatingLink(scheme); + await context.ChallengeAsync(scheme, properties); + } + + static string ResolveReturnUrl(string? returnUrl) + { + // The return URL is echoed back to the browser after the link completes, so it must be a same-site + // relative path (a single leading slash, not '//') — anything else falls back to the application + // root so the endpoint can never be used as an open redirect. + if (string.IsNullOrWhiteSpace(returnUrl)) + { + return ApplicationRoot; + } + + var isSafeRelative = Uri.TryCreate(returnUrl, UriKind.Relative, out _) + && returnUrl.StartsWith('/') + && !returnUrl.StartsWith("//", StringComparison.Ordinal); + + return isSafeRelative ? returnUrl : ApplicationRoot; + } + + bool SchemeExists(string scheme) + { + var config = authConfig.CurrentValue; + return config.OidcProviders.Any(provider => OidcProviderScheme.FromName(provider.Name) == scheme) + || config.OAuthProviders.Any(provider => OidcProviderScheme.FromName(provider.Name) == scheme); + } +} diff --git a/Source/AuthProxy/Links/LinkMiddlewareLogging.cs b/Source/AuthProxy/Links/LinkMiddlewareLogging.cs new file mode 100644 index 0000000..293cdc2 --- /dev/null +++ b/Source/AuthProxy/Links/LinkMiddlewareLogging.cs @@ -0,0 +1,13 @@ +// 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.Links; + +internal static partial class LinkMiddlewareLogging +{ + [LoggerMessage(LogLevel.Debug, "Initiating credential link challenge for scheme {Scheme}")] + internal static partial void InitiatingLink(this ILogger logger, string scheme); + + [LoggerMessage(LogLevel.Warning, "Credential link requested for provider {Scheme} which is not configured")] + internal static partial void LinkProviderNotConfigured(this ILogger logger, string scheme); +} diff --git a/Source/AuthProxy/Links/LinkSubjectExchanger.cs b/Source/AuthProxy/Links/LinkSubjectExchanger.cs new file mode 100644 index 0000000..0d3b0a6 --- /dev/null +++ b/Source/AuthProxy/Links/LinkSubjectExchanger.cs @@ -0,0 +1,87 @@ +// 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 System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Links; + +/// +/// Posts the freshly authenticated subject of a link challenge to the application's configured +/// . It mirrors the invite-exchange call +/// (): the subject and identity provider are read from the second +/// provider's principal and posted with the one-time link token as the bearer credential. Crucially, the +/// new identity is never signed in, so the user's primary session is preserved. +/// +/// The auth proxy configuration monitor. +/// The HTTP client factory used for the exchange call. +/// The logger. +public class LinkSubjectExchanger( + IOptionsMonitor config, + IHttpClientFactory httpClientFactory, + ILogger logger) : ILinkSubjectExchanger +{ + /// + public async Task Exchange(ClaimsPrincipal? principal, AuthenticationProperties properties) + { + var exchangeUrl = config.CurrentValue.Link?.ExchangeUrl; + if (string.IsNullOrWhiteSpace(exchangeUrl)) + { + logger.LinkExchangeUrlNotConfigured(); + return LinkExchangeResult.Failed; + } + + if (!properties.Items.TryGetValue(LinkMiddleware.LinkTokenPropertyKey, out var linkToken) + || string.IsNullOrWhiteSpace(linkToken)) + { + logger.LinkTokenMissing(); + return LinkExchangeResult.Failed; + } + + var subject = principal?.FindFirst("sub")?.Value + ?? principal?.FindFirst("oid")?.Value + ?? principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? principal?.FindFirst("id")?.Value + ?? string.Empty; + + var identityProvider = principal?.FindFirst("iss")?.Value + ?? principal?.FindFirst("identity_provider")?.Value + ?? principal?.FindFirst("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider")?.Value + ?? principal?.Identity?.AuthenticationType + ?? string.Empty; + + if (string.IsNullOrWhiteSpace(subject)) + { + logger.LinkSubjectMissing(); + return LinkExchangeResult.Failed; + } + + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, exchangeUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", linkToken); + request.Content = JsonContent.Create(new { subject, identityProvider }); + + HttpResponseMessage response; + try + { + response = await client.SendAsync(request); + } + catch (Exception ex) + { + logger.FailedToCallLinkExchangeEndpoint(ex, exchangeUrl); + return LinkExchangeResult.Failed; + } + + if (!response.IsSuccessStatusCode) + { + logger.LinkExchangeEndpointFailed((int)response.StatusCode); + return LinkExchangeResult.Failed; + } + + logger.LinkExchangedSuccessfully(); + return LinkExchangeResult.Success; + } +} diff --git a/Source/AuthProxy/Links/LinkSubjectExchangerLogging.cs b/Source/AuthProxy/Links/LinkSubjectExchangerLogging.cs new file mode 100644 index 0000000..20a4979 --- /dev/null +++ b/Source/AuthProxy/Links/LinkSubjectExchangerLogging.cs @@ -0,0 +1,25 @@ +// 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.Links; + +internal static partial class LinkSubjectExchangerLogging +{ + [LoggerMessage(LogLevel.Warning, "Link exchange URL is not configured - skipping link exchange")] + internal static partial void LinkExchangeUrlNotConfigured(this ILogger logger); + + [LoggerMessage(LogLevel.Warning, "Link exchange is missing the one-time link token - skipping link exchange")] + internal static partial void LinkTokenMissing(this ILogger logger); + + [LoggerMessage(LogLevel.Warning, "Link exchange could not resolve a subject from the authenticated identity")] + internal static partial void LinkSubjectMissing(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Failed to call link exchange endpoint at {Url}")] + internal static partial void FailedToCallLinkExchangeEndpoint(this ILogger logger, Exception exception, string url); + + [LoggerMessage(LogLevel.Warning, "Link exchange endpoint returned {StatusCode}")] + internal static partial void LinkExchangeEndpointFailed(this ILogger logger, int statusCode); + + [LoggerMessage(LogLevel.Information, "Link exchanged successfully")] + internal static partial void LinkExchangedSuccessfully(this ILogger logger); +} diff --git a/Source/AuthProxy/Links/LinksServiceCollectionExtensions.cs b/Source/AuthProxy/Links/LinksServiceCollectionExtensions.cs new file mode 100644 index 0000000..6393fdb --- /dev/null +++ b/Source/AuthProxy/Links/LinksServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +// 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.Links; + +/// +/// Extension methods for registering credential-linking services on . +/// +public static class LinksServiceCollectionExtensions +{ + /// + /// Registers the used by the session-preserving link flow. + /// + /// The to configure. + /// The same for chaining. + public static WebApplicationBuilder AddLinks(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + + return builder; + } +} diff --git a/Source/AuthProxy/LogoutMiddleware.cs b/Source/AuthProxy/LogoutMiddleware.cs new file mode 100644 index 0000000..14b2796 --- /dev/null +++ b/Source/AuthProxy/LogoutMiddleware.cs @@ -0,0 +1,135 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy; + +/// +/// Middleware that handles the well-known logout endpoint (). +/// It signs the user out of the authentication cookie, clears every AuthProxy session cookie and +/// redirects to the validated redirect query-string target. +/// +/// +/// The post-logout redirect target is an absolute URL and can therefore not be validated with the +/// relative-URL check used for tenant selection. Instead it is validated against an allow-list of +/// origins that combines the proxy's own public origin (honoring forwarded headers) with the +/// configured service frontends and lobby frontend. A missing or disallowed target falls back to the +/// application root (/) so the endpoint can never be turned into an open redirect. +/// +/// The next middleware in the pipeline. +/// The auth proxy configuration monitor. +public class LogoutMiddleware( + RequestDelegate next, + IOptionsMonitor config) +{ + const string RedirectQueryKey = "redirect"; + const string ApplicationRoot = "/"; + + /// + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments(WellKnownPaths.Logout)) + { + await next(context); + return; + } + + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + ClearSessionCookies(context); + + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = ResolveRedirectTarget(context, config.CurrentValue); + } + + static void ClearSessionCookies(HttpContext context) + { + context.Response.Cookies.Delete(Cookies.Identity); + context.Response.Cookies.Delete(Cookies.Tenant); + context.Response.Cookies.Delete(Cookies.Tenants); + context.Response.Cookies.Delete(Cookies.InviteToken); + context.Response.Cookies.Delete(Cookies.Registration); + context.Response.Cookies.Delete(Cookies.Providers); + } + + static string ResolveRedirectTarget(HttpContext context, C.AuthProxy authProxyConfig) + { + var requested = context.Request.Query[RedirectQueryKey].FirstOrDefault(); + return IsAllowedRedirect(context, authProxyConfig, requested) ? requested! : ApplicationRoot; + } + + static bool IsAllowedRedirect(HttpContext context, C.AuthProxy authProxyConfig, string? redirect) + { + if (string.IsNullOrWhiteSpace(redirect)) + { + return false; + } + + // A relative, single-slash URL is always same-site and therefore safe. + if (Uri.TryCreate(redirect, UriKind.Relative, out _) && redirect.StartsWith('/') && !redirect.StartsWith("//", StringComparison.Ordinal)) + { + return true; + } + + if (!Uri.TryCreate(redirect, UriKind.Absolute, out var target) + || (target.Scheme != Uri.UriSchemeHttp && target.Scheme != Uri.UriSchemeHttps)) + { + return false; + } + + var targetOrigin = target.GetLeftPart(UriPartial.Authority); + return GetAllowedOrigins(context, authProxyConfig) + .Contains(targetOrigin, StringComparer.OrdinalIgnoreCase); + } + + static IEnumerable GetAllowedOrigins(HttpContext context, C.AuthProxy authProxyConfig) + { + // The proxy's own public origin as seen by the browser (X-Forwarded-Proto is honored upstream). + if (context.Request.Host.HasValue + && Uri.TryCreate($"{context.Request.Scheme}://{context.Request.Host.Value}", UriKind.Absolute, out var self)) + { + yield return self.GetLeftPart(UriPartial.Authority); + } + + // Reuse the configured service frontends as permitted post-logout destinations. + foreach (var service in authProxyConfig.Services.Values) + { + if (TryGetOrigin(service.Frontend?.BaseUrl, out var origin)) + { + yield return origin; + } + } + + // The lobby frontend is also a permitted destination when configured. + if (TryGetOrigin(authProxyConfig.Invite?.Lobby?.Frontend?.BaseUrl, out var lobbyOrigin)) + { + yield return lobbyOrigin; + } + + // Any explicitly configured post-logout redirect origins (e.g. a separate marketing site). + foreach (var configured in authProxyConfig.Logout.AllowedRedirectOrigins) + { + if (TryGetOrigin(configured, out var configuredOrigin)) + { + yield return configuredOrigin; + } + } + } + + static bool TryGetOrigin(string? url, out string origin) + { + origin = string.Empty; + if (string.IsNullOrWhiteSpace(url) + || !Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return false; + } + + origin = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/Source/AuthProxy/Program.cs b/Source/AuthProxy/Program.cs index 4d2094c..990f39a 100644 --- a/Source/AuthProxy/Program.cs +++ b/Source/AuthProxy/Program.cs @@ -5,6 +5,7 @@ using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Invites; +using Cratis.AuthProxy.Links; using Cratis.AuthProxy.ReverseProxy; using Cratis.AuthProxy.Tenancy; @@ -17,6 +18,7 @@ builder.AddTenancy(); builder.AddIdentityResolution(); builder.AddInvites(); +builder.AddLinks(); builder.SetupReverseProxy(); var app = builder.Build(); diff --git a/Source/AuthProxy/TenantSelectionMiddleware.cs b/Source/AuthProxy/TenantSelectionMiddleware.cs index 0ae8481..a0d38e8 100644 --- a/Source/AuthProxy/TenantSelectionMiddleware.cs +++ b/Source/AuthProxy/TenantSelectionMiddleware.cs @@ -82,13 +82,15 @@ public async Task InvokeAsync(HttpContext context) return; } + // Written as a session cookie so that, once a multi-tenant user has selected a tenant, the + // tenant list remains available to the application's toolbar switcher for the rest of the + // browser session. It is intentionally not deleted on selection. 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( @@ -120,8 +122,8 @@ async Task HandleTenantSelection(HttpContext context, Tenancy.SelectionOptions s Secure = context.Request.IsHttps, }); - context.Response.Cookies.Delete(Cookies.Tenants); - + // The tenant list is intentionally retained (not deleted) so a multi-tenant user keeps the + // ability to switch tenants from the application's toolbar after making a selection. var requestedReturnUrl = context.Request.Query["returnUrl"].FirstOrDefault(); if (!IsSafeRelativeUrl(requestedReturnUrl)) { diff --git a/Source/AuthProxy/WellKnownPaths.cs b/Source/AuthProxy/WellKnownPaths.cs index 8fda761..0e214e3 100644 --- a/Source/AuthProxy/WellKnownPaths.cs +++ b/Source/AuthProxy/WellKnownPaths.cs @@ -41,6 +41,20 @@ public static class WellKnownPaths /// public const string SelectTenant = "/.cratis/select-tenant"; + /// + /// The well-known path that logs the current user out by clearing all session cookies + /// and redirecting to the URL supplied in the redirect query-string parameter. + /// + public const string Logout = "/.cratis/logout"; + + /// + /// The well-known path prefix for initiating a session-preserving link flow for a specific provider. + /// Append the scheme name to complete the URL (e.g. /.cratis/link/github). + /// Unlike , the link flow authenticates a second provider identity for the + /// already signed-in user without replacing the primary authentication cookie/session. + /// + public const string Link = "/.cratis/link"; + /// /// The well-known path prefix that triggers invite-token handling. /// Append the token to complete the URL (e.g. /invite/<token>).