diff --git a/Documentation/configuration/error-pages.md b/Documentation/configuration/error-pages.md index 770d464..a3d6a09 100644 --- a/Documentation/configuration/error-pages.md +++ b/Documentation/configuration/error-pages.md @@ -18,6 +18,7 @@ condition is detected: | `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 | +| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 | --- @@ -91,6 +92,15 @@ The built-in page reads the cookie with JavaScript and renders one sign-in butto You can override it with a custom branded version by placing your own `invitation-select-provider.html` in the configured pages directory. +### `invitation-subject-already-exists.html` + +Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict, +indicating that the authenticated user's subject is already associated with an existing account. +The user should sign in with their existing account rather than completing the invitation again. + +If you prefer to redirect users to a custom URL instead of serving this page, configure +`Invite.SubjectAlreadyExistsUrl` (see [Invites & Lobby](invites.md#invite-properties)). + --- ## Provider info cookie (`.cratis-providers`) diff --git a/Documentation/configuration/invites.md b/Documentation/configuration/invites.md index f494033..4f1cfbe 100644 --- a/Documentation/configuration/invites.md +++ b/Documentation/configuration/invites.md @@ -26,7 +26,10 @@ redirected while they complete the onboarding process. 1. After a successful OIDC login the user is redirected back. 2. AuthProxy detects the invite cookie, calls the configured `ExchangeUrl` with the token and the authenticated user's subject, then deletes the cookie. -3. If the exchange succeeds and a **lobby** service is configured, the user is redirected to +3. If the exchange endpoint returns **HTTP 409 Conflict** (subject already registered), AuthProxy + redirects to `SubjectAlreadyExistsUrl` (if configured) or serves the built-in + `invitation-subject-already-exists.html` page. +4. If the exchange succeeds and a **lobby** service is configured, the user is redirected to the lobby's frontend so they can enter the application with their newly assigned tenant. ### Lobby – no-tenant redirect @@ -56,6 +59,7 @@ All invite and lobby settings live under `Cratis:AuthProxy:Invite`: "Audience": "authproxy", "ExchangeUrl": "https://studio.example.com/internal/invites/exchange", "TenantClaim": "tenant_id", + "SubjectAlreadyExistsUrl": "https://app.example.com/errors/account-already-exists", "AppendInvitationIdToQueryString": true, "InvitationIdQueryStringKey": "invitationId", "ClaimsToForward": [ @@ -81,6 +85,7 @@ All invite and lobby settings live under `Cratis:AuthProxy:Invite`: | `Audience` | `string` | Expected `aud` claim. Leave empty to skip audience validation. | | `ExchangeUrl` | `string` | Absolute URL of the invite-exchange endpoint, e.g. `https://studio.example.com/internal/invites/exchange`. | | `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID string for tenant-issued invite detection. | +| `SubjectAlreadyExistsUrl` | `string` | URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered). Leave empty to serve the built-in `invitation-subject-already-exists.html` page. | | `AppendInvitationIdToQueryString` | `bool` | Appends `jti` from the invite token to the lobby redirect query string when enabled. | | `InvitationIdQueryStringKey` | `string` | Query string key used when appending invitation ID. | | `ClaimsToForward` | `InviteClaimForwarding[]` | Claim mappings forwarded from invite token into the principal sent to identity details providers. | @@ -150,8 +155,16 @@ AuthProxy distinguishes between two token failure modes and serves a dedicated p | `invitation-expired.html` | The token had a valid signature but has passed its `exp` claim. | | `invitation-invalid.html` | The token is malformed, carries an invalid signature, or cannot be parsed. | | `invitation-select-provider.html` | The token is valid and multiple identity providers are configured. | +| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account (exchange returned HTTP 409). | -All error pages are served with HTTP 401 except `invitation-select-provider.html` which uses HTTP 200. +All error pages are served with the following HTTP status codes: + +| Page | HTTP status | +|------|-------------| +| `invitation-expired.html` | 401 | +| `invitation-invalid.html` | 401 | +| `invitation-select-provider.html` | 200 | +| `invitation-subject-already-exists.html` | 409 | These pages use full descriptive names rather than numeric error codes because they represent application-level conditions, not generic HTTP errors. diff --git a/Documentation/configuration/well-known-pages.md b/Documentation/configuration/well-known-pages.md index d5da758..123d155 100644 --- a/Documentation/configuration/well-known-pages.md +++ b/Documentation/configuration/well-known-pages.md @@ -20,6 +20,7 @@ condition is detected: | `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 | +| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 | --- @@ -100,6 +101,11 @@ passed its `exp` claim. The user should request a fresh invitation. Served when the token on an `/invite/` link is malformed, carries an invalid signature, or cannot be parsed at all. This typically indicates a truncated or otherwise corrupted link. +### `invitation-subject-already-exists.html` + +Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict, +indicating that the authenticated user's subject is already associated with an existing account. + --- ## Tenant not found diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status.cs new file mode 100644 index 0000000..cfd72c7 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status.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 System.Net; + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_authenticated_user_has_pending_invite; + +public class and_exchange_returns_duplicate_subject_status : Specification +{ + InviteMiddleware _middleware; + DefaultHttpContext _context; + IErrorPageProvider _errorPageProvider; + bool _nextCalled; + + void Establish() + { + var tokenValidator = Substitute.For(); + + var config = new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "http://studio/internal/invites/exchange" + } + }; + var optionsMonitor = Substitute.For>(); + optionsMonitor.CurrentValue.Returns(config); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns( + new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.Conflict))); + + _errorPageProvider = Substitute.For(); + + _middleware = new InviteMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + tokenValidator, + optionsMonitor, + CreateEmptyAuthConfig(), + Substitute.For(), + httpClientFactory, + _errorPageProvider, + Substitute.For>()); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/"; + + var identity = new ClaimsIdentity([new Claim("sub", "user-123")], "aad"); + _context.User = new ClaimsPrincipal(identity); + _context.Request.Headers.Cookie = $"{Cookies.InviteToken}=pending-invite-token"; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); + [Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); + [Fact] + void should_serve_subject_already_exists_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync( + _context, + WellKnownPageNames.InvitationSubjectAlreadyExists, + StatusCodes.Status409Conflict); + + static IOptionsMonitor CreateEmptyAuthConfig() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.Authentication()); + return monitor; + } +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status_with_configured_url.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status_with_configured_url.cs new file mode 100644 index 0000000..f7e5b4a --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_invite/and_exchange_returns_duplicate_subject_status_with_configured_url.cs @@ -0,0 +1,73 @@ +// 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.Invites.for_InviteMiddleware.when_authenticated_user_has_pending_invite; + +public class and_exchange_returns_duplicate_subject_status_with_configured_url : Specification +{ + const string SubjectAlreadyExistsUrl = "https://app.example.com/errors/account-already-exists"; + + InviteMiddleware _middleware; + DefaultHttpContext _context; + IErrorPageProvider _errorPageProvider; + bool _nextCalled; + + void Establish() + { + var tokenValidator = Substitute.For(); + + var config = new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "http://studio/internal/invites/exchange", + SubjectAlreadyExistsUrl = SubjectAlreadyExistsUrl + } + }; + var optionsMonitor = Substitute.For>(); + optionsMonitor.CurrentValue.Returns(config); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns( + new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.Conflict))); + + _errorPageProvider = Substitute.For(); + + _middleware = new InviteMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + tokenValidator, + optionsMonitor, + CreateEmptyAuthConfig(), + Substitute.For(), + httpClientFactory, + _errorPageProvider, + Substitute.For>()); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/"; + + var identity = new ClaimsIdentity([new Claim("sub", "user-123")], "aad"); + _context.User = new ClaimsPrincipal(identity); + _context.Request.Headers.Cookie = $"{Cookies.InviteToken}=pending-invite-token"; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse(); + [Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); + [Fact] void should_redirect_to_configured_url() => _context.Response.Headers.Location.ToString().ShouldEqual(SubjectAlreadyExistsUrl); + [Fact] void should_not_serve_error_page() => _errorPageProvider.DidNotReceiveWithAnyArgs().WriteErrorPageAsync(default!, default!, default); + + static IOptionsMonitor CreateEmptyAuthConfig() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.Authentication()); + return monitor; + } +} diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs index cc9c84a..1a1b9e1 100644 --- a/Source/AuthProxy/Configuration/Invite.cs +++ b/Source/AuthProxy/Configuration/Invite.cs @@ -31,6 +31,15 @@ public class Invite /// public string ExchangeUrl { get; set; } = string.Empty; + /// + /// Gets or sets the URL to redirect to when the authenticated user's subject is already + /// associated with an existing user during the invite exchange (Phase 2). + /// When set, the user is redirected to this URL instead of the built-in + /// invitation-subject-already-exists.html page. + /// Leave empty to serve the built-in well-known error page. + /// + public string SubjectAlreadyExistsUrl { get; set; } = string.Empty; + /// /// Gets or sets the claim name in the invite token that holds the tenant ID. /// When set, a tenant-issued invite is recognized when this claim's value matches diff --git a/Source/AuthProxy/Invites/InviteExchangeResult.cs b/Source/AuthProxy/Invites/InviteExchangeResult.cs new file mode 100644 index 0000000..175535a --- /dev/null +++ b/Source/AuthProxy/Invites/InviteExchangeResult.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. + +namespace Cratis.AuthProxy.Invites; + +/// +/// Represents the outcome of an invite exchange request. +/// +enum InviteExchangeResult +{ + /// The exchange completed successfully. + Success = 0, + + /// The exchange was rejected because the subject is already associated with an existing user (HTTP 409). + DuplicateSubject = 1, + + /// The exchange failed for any other reason. + Failed = 2, +} diff --git a/Source/AuthProxy/Invites/InviteMiddleware.cs b/Source/AuthProxy/Invites/InviteMiddleware.cs index 00db4ae..9b0e639 100644 --- a/Source/AuthProxy/Invites/InviteMiddleware.cs +++ b/Source/AuthProxy/Invites/InviteMiddleware.cs @@ -75,10 +75,28 @@ public async Task InvokeAsync(HttpContext context) if (context.User.Identity?.IsAuthenticated == true && context.TryGetPendingInvitationToken(out var inviteToken)) { - var exchangeSucceeded = await ExchangeInvite(context, inviteToken); + var exchangeResult = await ExchangeInvite(context, inviteToken); context.Response.Cookies.Delete(Cookies.InviteToken); - if (exchangeSucceeded && !IsTenantIssuedInvite(inviteToken, context)) + if (exchangeResult == InviteExchangeResult.DuplicateSubject) + { + var subjectAlreadyExistsUrl = config.CurrentValue.Invite?.SubjectAlreadyExistsUrl; + if (!string.IsNullOrWhiteSpace(subjectAlreadyExistsUrl)) + { + context.Response.Redirect(subjectAlreadyExistsUrl); + } + else + { + await errorPageProvider.WriteErrorPageAsync( + context, + WellKnownPageNames.InvitationSubjectAlreadyExists, + StatusCodes.Status409Conflict); + } + + return; + } + + if (exchangeResult == InviteExchangeResult.Success && !IsTenantIssuedInvite(inviteToken, context)) { var lobbyUrl = config.CurrentValue.Invite?.Lobby?.Frontend?.BaseUrl; if (!string.IsNullOrWhiteSpace(lobbyUrl)) @@ -175,13 +193,13 @@ static IEnumerable GetAllProviders(C.Authentication config) => config.OidcProviders.Select(OidcProviderScheme.ToProviderInfo) .Concat(config.OAuthProviders.Select(OidcProviderScheme.ToProviderInfo)); - async Task ExchangeInvite(HttpContext context, string inviteToken) + async Task ExchangeInvite(HttpContext context, string inviteToken) { var exchangeUrl = config.CurrentValue.Invite?.ExchangeUrl; if (string.IsNullOrWhiteSpace(exchangeUrl)) { logger.InviteExchangeUrlNotConfigured(); - return false; + return InviteExchangeResult.Failed; } var subject = context.User.FindFirst("sub")?.Value @@ -209,17 +227,23 @@ async Task ExchangeInvite(HttpContext context, string inviteToken) catch (Exception ex) { logger.FailedToCallInviteExchangeEndpoint(ex, exchangeUrl); - return false; + return InviteExchangeResult.Failed; + } + + if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + { + logger.InviteSubjectAlreadyExists(subject); + return InviteExchangeResult.DuplicateSubject; } if (!response.IsSuccessStatusCode) { logger.InviteExchangeEndpointFailed((int)response.StatusCode, subject); - return false; + return InviteExchangeResult.Failed; } logger.InviteExchangedSuccessfully(subject); - return true; + return InviteExchangeResult.Success; } bool IsTenantIssuedInvite(string inviteToken, HttpContext context) diff --git a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs index b2aa230..327a427 100644 --- a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs +++ b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs @@ -19,4 +19,7 @@ internal static partial class InviteMiddlewareLogging [LoggerMessage(LogLevel.Information, "Invite exchanged successfully for subject {Subject}")] internal static partial void InviteExchangedSuccessfully(this ILogger logger, string subject); + + [LoggerMessage(LogLevel.Warning, "Invite exchange rejected because subject {Subject} is already associated with an existing user")] + internal static partial void InviteSubjectAlreadyExists(this ILogger logger, string subject); } diff --git a/Source/AuthProxy/Pages/invitation-subject-already-exists.html b/Source/AuthProxy/Pages/invitation-subject-already-exists.html new file mode 100644 index 0000000..c847951 --- /dev/null +++ b/Source/AuthProxy/Pages/invitation-subject-already-exists.html @@ -0,0 +1,22 @@ + + + + + + Account Already Exists + + + +
+

Account Already Exists

+

An account is already registered for your identity.

+

Please sign in with your existing account instead of accepting the invitation again.

+
+ + diff --git a/Source/AuthProxy/WellKnownPageNames.cs b/Source/AuthProxy/WellKnownPageNames.cs index c386a21..419b1c8 100644 --- a/Source/AuthProxy/WellKnownPageNames.cs +++ b/Source/AuthProxy/WellKnownPageNames.cs @@ -33,6 +33,12 @@ public static class WellKnownPageNames ///
public const string InvitationInvalid = "invitation-invalid.html"; + /// + /// The page returned when an authenticated user attempts to accept an invitation but the subject + /// is already associated with an existing user. + /// + public const string InvitationSubjectAlreadyExists = "invitation-subject-already-exists.html"; + /// /// The page served when a valid invitation token is presented and multiple identity providers /// are configured. The page reads the .cratis-providers cookie injected by the proxy