diff --git a/Documentation/configuration/lobby/invitation-for-creating-organization.md b/Documentation/configuration/lobby/invitation-for-creating-organization.md index 4d6ba72..dc5e07c 100644 --- a/Documentation/configuration/lobby/invitation-for-creating-organization.md +++ b/Documentation/configuration/lobby/invitation-for-creating-organization.md @@ -12,8 +12,11 @@ frontend to finish onboarding. - With one configured identity provider, AuthProxy challenges that provider immediately. - With multiple providers, AuthProxy serves `invitation-select-provider.html` so the user can choose how to sign in. -4. After a successful login, AuthProxy calls `Invite.ExchangeUrl` with the invite token and the - authenticated user's subject. +4. After a successful login, AuthProxy re-validates the token, then calls `Invite.ExchangeUrl` with the + invite token and the authenticated user's subject and verified email. + - The token is re-validated (signature, issuer, audience, lifetime) before it is forwarded. + - If the token was issued for a specific email (`Invite.EmailClaim`), the account's verified email + must match, otherwise `invitation-email-mismatch.html` is served. 5. If the exchange succeeds, AuthProxy redirects the user to `Invite.Lobby.Frontend.BaseUrl`. This flow is the right fit when the invited user is not entering an already-resolved tenant. diff --git a/Documentation/configuration/lobby/invitation-to-organization.md b/Documentation/configuration/lobby/invitation-to-organization.md index 3c547d9..4139cfa 100644 --- a/Documentation/configuration/lobby/invitation-to-organization.md +++ b/Documentation/configuration/lobby/invitation-to-organization.md @@ -8,10 +8,16 @@ user can continue directly into the application instead of being sent to the lob 1. The user opens `https://your-authproxy/invite/`. 2. AuthProxy validates the token and starts authentication in the same way as any other invite. -3. After login, AuthProxy exchanges the invite at `Invite.ExchangeUrl`. -4. AuthProxy compares the configured `Invite.TenantClaim` from the token with the resolved tenant +3. After login, AuthProxy **re-validates the token** (signature, issuer, audience, and lifetime) before + forwarding it, so AuthProxy is the authoritative validator across both phases. +4. AuthProxy binds the invite to its recipient: if the token carries the `Invite.EmailClaim` claim, the + account's provider-verified email must match it, otherwise the exchange is refused and the + `invitation-email-mismatch.html` page is served. +5. AuthProxy exchanges the invite at `Invite.ExchangeUrl`, forwarding the authenticated account's + verified email so the backend can apply its own defense-in-depth check. +6. AuthProxy compares the configured `Invite.TenantClaim` from the token with the resolved tenant for the request. -5. If the tenant IDs match, AuthProxy skips the lobby redirect and continues to the target service. +7. If the tenant IDs match, AuthProxy skips the lobby redirect and continues to the target service. If the tenant IDs do not match, or AuthProxy cannot resolve a tenant for the request, the invite is treated like lobby onboarding and falls back to the configured lobby behavior. @@ -25,6 +31,7 @@ treated like lobby onboarding and falls back to the configured lobby behavior. "Invite": { "ExchangeUrl": "https://studio.example.com/internal/invites/exchange", "TenantClaim": "tenant_id", + "EmailClaim": "email", "Lobby": { "Frontend": { "BaseUrl": "http://lobby-service:3000/" } } @@ -38,6 +45,7 @@ treated like lobby onboarding and falls back to the configured lobby behavior. |----------|------|-------------| | `ExchangeUrl` | `string` | Absolute URL of the invite exchange endpoint. | | `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID. | +| `EmailClaim` | `string` | Claim in the invite token that contains the email the invitation was issued for. Defaults to `email`. When the token carries it, the authenticated account's verified email must match. Set to an empty string to disable email-binding enforcement (the verified email is still forwarded to the exchange endpoint). | | `Lobby.Frontend.BaseUrl` | `string` | Fallback redirect if the invite cannot continue directly into the organization. | ## Requirements diff --git a/Documentation/configuration/well-known-pages.md b/Documentation/configuration/well-known-pages.md index 15f0c1e..61d7058 100644 --- a/Documentation/configuration/well-known-pages.md +++ b/Documentation/configuration/well-known-pages.md @@ -18,10 +18,11 @@ condition is detected: | `tenant-not-found.html` | The resolved tenant does not exist in the platform (see [Tenant verification](tenancy.md#tenant-verification)). | 404 | | `select-provider.html` | A protected resource was requested and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 | | `select-tenant.html` | Tenant selection is enabled and the authenticated user has not selected a tenant yet. The page reads the `.cratis-tenants` cookie to render selectable tenants. | 200 | -| `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 | -| `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 | +| `invitation-expired.html` | The JWT token on an invite link has passed its expiry time. Served in either phase — when the link is followed (Phase 1) and when the token is re-validated at the exchange (Phase 2). | 401 | +| `invitation-invalid.html` | The JWT token on an invite link is malformed or has an invalid signature. Served in either phase — when the link is followed (Phase 1) and when the token is re-validated at the exchange (Phase 2). | 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 | +| `invitation-email-mismatch.html` | The account signed in with has a verified email that does not match the email the invitation was issued for (Phase 2). | 403 | --- @@ -94,19 +95,30 @@ they represent application-level conditions, not generic HTTP errors. ### `invitation-expired.html` -Served when a user follows an `/invite/` link whose JWT has a valid signature but has -passed its `exp` claim. The user should request a fresh invitation. +Served when an invite token has a valid signature but has passed its `exp` claim. AuthProxy validates +the token both when the `/invite/` link is followed (Phase 1) and again at the post-login +exchange (Phase 2), so this page is served whenever an expired token is detected in either phase. +The user should request a fresh invitation. ### `invitation-invalid.html` -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. +Served when an invite token is malformed, carries an invalid signature, or cannot be parsed at all. +Because AuthProxy re-validates the token at the Phase-2 exchange — not only when the link is first +followed — this page is also served if a tampered or forged token is presented at the exchange. ### `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. +### `invitation-email-mismatch.html` + +Served during Phase 2 (post-login invite exchange) when the invite token was issued for a specific +email address (the `Invite.EmailClaim` claim) and the account the user signed in with does not own +that verified email. This binds an invitation to its intended recipient so it cannot be redeemed with +a different account. See [Invitation to Organization](lobby/invitation-to-organization.md) for how to +configure the email claim. + --- ## Tenant not found diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs new file mode 100644 index 0000000..b33efbb --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs @@ -0,0 +1,126 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.given; + +/// +/// Reusable context that drives through its Phase-2 (post-login) +/// invite exchange using a real and a signed invite token, +/// so re-validation and email binding are actually exercised. Concrete specs set the pending invite +/// cookie and the authenticated user in their own Establish, then invoke the middleware. +/// +public class an_invite_exchange : Specification +{ + protected const string ExchangeUrl = "http://studio/internal/invites/exchange"; + protected const string Issuer = "test-issuer"; + protected const string Audience = "test-audience"; + + protected RsaSecurityKey _signingKey; + protected InviteMiddleware _middleware; + protected DefaultHttpContext _context; + protected IErrorPageProvider _errorPageProvider; + protected bool _nextCalled; + protected bool _exchangeCalled; + protected string _exchangeRequestBody = string.Empty; + + void Establish() + { + var (privateKey, publicKeyPem) = TokenFixture.GenerateKeyPair(); + _signingKey = privateKey; + + var config = new C.AuthProxy + { + Invite = new C.Invite + { + PublicKeyPem = publicKeyPem, + Issuer = Issuer, + Audience = Audience, + ExchangeUrl = ExchangeUrl, + } + }; + var optionsMonitor = Substitute.For>(); + optionsMonitor.CurrentValue.Returns(config); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(new CapturingHandler(this))); + + _errorPageProvider = Substitute.For(); + _errorPageProvider + .WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _middleware = new InviteMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + new InviteTokenValidator(optionsMonitor), + optionsMonitor, + EmptyAuthConfig(), + Substitute.For(), + httpClientFactory, + _errorPageProvider, + Substitute.For>()); + + _context = new DefaultHttpContext(); + _context.Request.Path = "/"; + } + + /// + /// Creates a signed invite token, overriding the signing key or standard claims when a test + /// needs to forge, expire, or otherwise diverge from a valid token. + /// + /// The key to sign with; defaults to the configured (trusted) key. + /// The iss claim; defaults to the expected issuer. + /// The aud claim; defaults to the expected audience. + /// The expiry time; defaults to one hour from now. + /// The earliest valid time; defaults to one minute ago. + /// Additional claims to embed in the token. + /// The compact-serialized signed JWT. + protected string CreateSignedToken( + RsaSecurityKey? signingKey = null, + string issuer = Issuer, + string audience = Audience, + DateTime? expires = null, + DateTime? notBefore = null, + IEnumerable? claims = null) => + TokenFixture.CreateToken(signingKey ?? _signingKey, issuer, audience, expires, notBefore, claims); + + /// + /// Marks the request as authenticated, always carrying a sub claim plus any supplied claims. + /// + /// Additional claims (e.g. email, email_verified) for the account. + protected void GivenAuthenticatedUserWith(params Claim[] claims) => + _context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.Prepend(new Claim("sub", "user-123")), "aad")); + + /// + /// Places the given token in the pending invite cookie, as it would arrive on the Phase-2 request. + /// + /// The token to carry in the .cratis-invite cookie. + protected void GivenPendingInviteCookie(string token) => + _context.Request.Headers.Cookie = $"{Cookies.InviteToken}={token}"; + + static IOptionsMonitor EmptyAuthConfig() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.Authentication()); + return monitor; + } + + sealed class CapturingHandler(an_invite_exchange owner) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + owner._exchangeCalled = true; + if (request.Content is not null) + { + owner._exchangeRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + } + + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + } + } +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_a_different_account_email_is_used.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_a_different_account_email_is_used.cs new file mode 100644 index 0000000..c9c48c4 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_a_different_account_email_is_used.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.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email; + +public class and_a_different_account_email_is_used : given.an_invite_exchange +{ + void Establish() + { + // The account that logged in is not the one the invitation was issued for. + GivenAuthenticatedUserWith(new Claim("email", "attacker@example.com"), new Claim("email_verified", "true")); + GivenPendingInviteCookie(CreateSignedToken(claims: [new Claim("email", "invited@example.com")])); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_email_mismatch_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden); + [Fact] void should_delete_the_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_authenticated_email_is_not_verified.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_authenticated_email_is_not_verified.cs new file mode 100644 index 0000000..a95a4d6 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_authenticated_email_is_not_verified.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email; + +public class and_the_authenticated_email_is_not_verified : given.an_invite_exchange +{ + const string InvitedEmail = "invited@example.com"; + + void Establish() + { + // The email matches, but the provider explicitly reports it as unverified. + GivenAuthenticatedUserWith(new Claim("email", InvitedEmail), new Claim("email_verified", "false")); + GivenPendingInviteCookie(CreateSignedToken(claims: [new Claim("email", InvitedEmail)])); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_email_mismatch_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_invite_targets_no_email.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_invite_targets_no_email.cs new file mode 100644 index 0000000..375c2c1 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_invite_targets_no_email.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. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email; + +public class and_the_invite_targets_no_email : given.an_invite_exchange +{ + void Establish() + { + // Backward compatibility: an invite that carries no email claim has nothing to bind against. + GivenAuthenticatedUserWith(new Claim("email", "anyone@example.com"), new Claim("email_verified", "true")); + GivenPendingInviteCookie(CreateSignedToken()); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeTrue(); + [Fact] void should_continue_the_pipeline() => _nextCalled.ShouldBeTrue(); + [Fact] void should_not_serve_an_error_page() => + _errorPageProvider.DidNotReceive().WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_verified_email_is_forwarded_to_the_exchange.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_verified_email_is_forwarded_to_the_exchange.cs new file mode 100644 index 0000000..91a5ae6 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_verified_email_is_forwarded_to_the_exchange.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. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email; + +public class and_the_verified_email_is_forwarded_to_the_exchange : given.an_invite_exchange +{ + const string InvitedEmail = "invited@example.com"; + + void Establish() + { + GivenAuthenticatedUserWith(new Claim("email", InvitedEmail), new Claim("email_verified", "true")); + GivenPendingInviteCookie(CreateSignedToken(claims: [new Claim("email", InvitedEmail)])); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_include_the_email_in_the_exchange_body() => _exchangeRequestBody.ShouldContain(InvitedEmail); + [Fact] void should_include_the_email_field_in_the_exchange_body() => _exchangeRequestBody.ShouldContain("email"); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_verified_email_matches.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_verified_email_matches.cs new file mode 100644 index 0000000..b9f0c05 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_verified_email_matches.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.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email; + +public class and_verified_email_matches : given.an_invite_exchange +{ + const string InvitedEmail = "invited@example.com"; + + void Establish() + { + GivenAuthenticatedUserWith(new Claim("email", InvitedEmail), new Claim("email_verified", "true")); + GivenPendingInviteCookie(CreateSignedToken(claims: [new Claim("email", InvitedEmail)])); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeTrue(); + [Fact] void should_continue_the_pipeline() => _nextCalled.ShouldBeTrue(); + [Fact] void should_not_serve_an_error_page() => + _errorPageProvider.DidNotReceive().WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_audience.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_audience.cs new file mode 100644 index 0000000..f47269c --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_audience.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. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_has_wrong_audience : given.an_invite_exchange +{ + void Establish() + { + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateSignedToken(audience: "https://evil.example.com")); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_invalid_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationInvalid, StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_issuer.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_issuer.cs new file mode 100644 index 0000000..6e852ed --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_has_wrong_issuer.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. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_has_wrong_issuer : given.an_invite_exchange +{ + void Establish() + { + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateSignedToken(issuer: "https://evil.example.com")); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_invalid_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationInvalid, StatusCodes.Status401Unauthorized); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_expired.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_expired.cs new file mode 100644 index 0000000..bcd2b67 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_expired.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_is_expired : given.an_invite_exchange +{ + void Establish() + { + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateSignedToken( + expires: DateTime.UtcNow.AddMinutes(-10), + notBefore: DateTime.UtcNow.AddMinutes(-20))); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_expired_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationExpired, StatusCodes.Status401Unauthorized); + [Fact] void should_delete_the_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_unsigned.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_unsigned.cs new file mode 100644 index 0000000..fc634b9 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_unsigned.cs @@ -0,0 +1,33 @@ +// 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.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_is_unsigned : given.an_invite_exchange +{ + void Establish() + { + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateUnsignedToken()); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_invalid_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationInvalid, StatusCodes.Status401Unauthorized); + + static string CreateUnsignedToken() + { + // Models the exact threat: a self-crafted unsigned JWT (alg=none) with a future exp and a jti + // equal to a known pending invitation id, placed in the .cratis-invite cookie after a normal login. + var exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(); + var header = Base64Url("{\"alg\":\"none\",\"typ\":\"JWT\"}"); + var payload = Base64Url($"{{\"jti\":\"known-invitation-id\",\"iss\":\"{Issuer}\",\"aud\":\"{Audience}\",\"exp\":{exp}}}"); + return $"{header}.{payload}."; + } + + static string Base64Url(string value) => + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_validly_signed.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_validly_signed.cs new file mode 100644 index 0000000..1964cfb --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_is_validly_signed.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. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_is_validly_signed : given.an_invite_exchange +{ + void Establish() + { + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateSignedToken()); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeTrue(); + [Fact] void should_continue_the_pipeline() => _nextCalled.ShouldBeTrue(); + [Fact] void should_not_serve_an_error_page() => + _errorPageProvider.DidNotReceive().WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_signature_is_forged.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_signature_is_forged.cs new file mode 100644 index 0000000..de63321 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_invite_token_is_re_validated_at_exchange/and_token_signature_is_forged.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_invite_token_is_re_validated_at_exchange; + +public class and_token_signature_is_forged : given.an_invite_exchange +{ + void Establish() + { + // Signed with a key AuthProxy does not trust — a self-crafted token the caller placed in the cookie. + var (attackerKey, _) = TokenFixture.GenerateKeyPair(); + GivenAuthenticatedUserWith(); + GivenPendingInviteCookie(CreateSignedToken(signingKey: attackerKey)); + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_not_forward_to_the_exchange_endpoint() => _exchangeCalled.ShouldBeFalse(); + [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse(); + [Fact] void should_serve_the_invitation_invalid_page() => + _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationInvalid, StatusCodes.Status401Unauthorized); + [Fact] void should_delete_the_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs new file mode 100644 index 0000000..f0cf503 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.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.Invites; + +namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used; + +/// +/// End-to-end scenario for the Phase-2 trust boundary: an authenticated caller places a self-crafted, +/// untrusted-signed token in the .cratis-invite cookie (which HTTP-only does not prevent). +/// AuthProxy must re-validate the token at the exchange forward and refuse to hand it to the exchange endpoint. +/// +/// The shared application factory. +public class and_forged_token_is_presented_at_exchange(AuthProxyFactory factory) + : IClassFixture, IAsyncLifetime +{ + HttpResponseMessage? _response; + string? _responseBody; + + public async Task InitializeAsync() + { + var (attackerKey, _) = TokenFixture.GenerateKeyPair(); + var forgedToken = TokenFixture.CreateToken(attackerKey); + + using var client = factory.CreateTestClient(authenticated: true, inviteTokenCookie: forgedToken); + _response = await client.GetAsync("/"); + _responseBody = await _response.Content.ReadAsStringAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_return_401() => Assert.Equal(System.Net.HttpStatusCode.Unauthorized, _response.StatusCode); + [Fact] public void should_not_call_exchange_endpoint() => Assert.Equal(0, factory.ExchangeCallCount); + [Fact] public void should_return_invitation_invalid_page() => Assert.Contains("Invitation Invalid", _responseBody); +} diff --git a/Source/AuthProxy.Specs/for_TenancyMiddleware/when_tenant_resolution_fails/and_user_is_authenticated_without_organization.cs b/Source/AuthProxy.Specs/for_TenancyMiddleware/when_tenant_resolution_fails/and_user_is_authenticated_without_organization.cs index 297211b..8e65a8c 100644 --- a/Source/AuthProxy.Specs/for_TenancyMiddleware/when_tenant_resolution_fails/and_user_is_authenticated_without_organization.cs +++ b/Source/AuthProxy.Specs/for_TenancyMiddleware/when_tenant_resolution_fails/and_user_is_authenticated_without_organization.cs @@ -1,8 +1,6 @@ // 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; - namespace Cratis.AuthProxy.for_TenancyMiddleware.when_tenant_resolution_fails; public class and_user_is_authenticated_without_organization : Specification diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs index 8769863..884fea6 100644 --- a/Source/AuthProxy/Configuration/Invite.cs +++ b/Source/AuthProxy/Configuration/Invite.cs @@ -49,6 +49,16 @@ public class Invite /// public string TenantClaim { get; set; } = string.Empty; + /// + /// Gets or sets the claim name in the invite token that holds the email address the + /// invitation was issued for. Defaults to email. + /// When the (validated) invite token carries this claim, the authenticating account's + /// verified email must match it at the Phase-2 exchange or the invite is rejected, binding + /// the invitation to its intended recipient. Leave empty to disable email-binding enforcement + /// (the authenticated email is still forwarded to the exchange endpoint for app-level checks). + /// + public string EmailClaim { get; set; } = "email"; + /// /// Gets or sets a value indicating whether the invitation ID from the invite token (jti) /// should be appended to the lobby redirect URL query string after a successful invite exchange. diff --git a/Source/AuthProxy/Invites/InviteExchangeResult.cs b/Source/AuthProxy/Invites/InviteExchangeResult.cs index 175535a..be7891d 100644 --- a/Source/AuthProxy/Invites/InviteExchangeResult.cs +++ b/Source/AuthProxy/Invites/InviteExchangeResult.cs @@ -16,4 +16,10 @@ enum InviteExchangeResult /// The exchange failed for any other reason. Failed = 2, + + /// + /// The exchange was not attempted because the authenticating account's verified email did not match + /// the email the invitation was issued for. + /// + EmailMismatch = 3, } diff --git a/Source/AuthProxy/Invites/InviteMiddleware.cs b/Source/AuthProxy/Invites/InviteMiddleware.cs index 9b0e639..d682cd9 100644 --- a/Source/AuthProxy/Invites/InviteMiddleware.cs +++ b/Source/AuthProxy/Invites/InviteMiddleware.cs @@ -75,9 +75,41 @@ public async Task InvokeAsync(HttpContext context) if (context.User.Identity?.IsAuthenticated == true && context.TryGetPendingInvitationToken(out var inviteToken)) { + // Re-validate the invite token before forwarding it (Phase 2). The token arrives from the + // HTTP-only .cratis-invite cookie, but HTTP-only only blocks browser JS - an authenticated + // caller can still set the cookie to a self-crafted token. AuthProxy must therefore be the + // authoritative invite-token validator across BOTH phases, re-checking the RSA signature, + // issuer, audience and lifetime here before handing the token to the exchange endpoint. + var validationResult = tokenValidator.ValidateDetailed(inviteToken); + if (validationResult != InviteTokenValidationResult.Valid) + { + logger.InviteExchangeTokenValidationFailed(context.Request.Path); + context.Response.Cookies.Delete(Cookies.InviteToken); + + var invalidPageName = validationResult == InviteTokenValidationResult.Expired + ? WellKnownPageNames.InvitationExpired + : WellKnownPageNames.InvitationInvalid; + + await errorPageProvider.WriteErrorPageAsync( + context, + invalidPageName, + StatusCodes.Status401Unauthorized); + return; + } + var exchangeResult = await ExchangeInvite(context, inviteToken); context.Response.Cookies.Delete(Cookies.InviteToken); + if (exchangeResult == InviteExchangeResult.EmailMismatch) + { + await errorPageProvider.WriteErrorPageAsync( + context, + WellKnownPageNames.InvitationEmailMismatch, + StatusCodes.Status403Forbidden); + + return; + } + if (exchangeResult == InviteExchangeResult.DuplicateSubject) { var subjectAlreadyExistsUrl = config.CurrentValue.Invite?.SubjectAlreadyExistsUrl; @@ -193,6 +225,26 @@ static IEnumerable GetAllProviders(C.Authentication config) => config.OidcProviders.Select(OidcProviderScheme.ToProviderInfo) .Concat(config.OAuthProviders.Select(OidcProviderScheme.ToProviderInfo)); + /// + /// Resolves the authenticating account's email and its provider-supplied verification status. + /// + /// The current . + /// + /// The value of the provider's email_verified claim when present; otherwise . + /// + /// The authenticated email, or an empty string when none is available. + static string ResolveAuthenticatedEmail(HttpContext context, out bool? emailVerified) + { + emailVerified = bool.TryParse(context.User.FindFirst("email_verified")?.Value, out var verified) + ? verified + : null; + + return context.User.FindFirst("email")?.Value + ?? context.User.FindFirst(ClaimTypes.Email)?.Value + ?? context.User.FindFirst("preferred_username")?.Value + ?? string.Empty; + } + async Task ExchangeInvite(HttpContext context, string inviteToken) { var exchangeUrl = config.CurrentValue.Invite?.ExchangeUrl; @@ -214,10 +266,24 @@ async Task ExchangeInvite(HttpContext context, string invi ?? context.User.Identity?.AuthenticationType ?? string.Empty; + var email = ResolveAuthenticatedEmail(context, out var emailVerified); + + // An invitation is otherwise a pure bearer link - anyone with the URL could sign in with their + // own account and be provisioned as the invited user. Bind the invite to its intended recipient + // by requiring the authenticating account's verified email to match the invited email. + if (!IsInvitedEmailBound(inviteToken, email, emailVerified)) + { + logger.InviteEmailMismatch(subject); + return InviteExchangeResult.EmailMismatch; + } + using var client = httpClientFactory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, exchangeUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", inviteToken); - request.Content = JsonContent.Create(new { subject, identityProvider }); + + // Forward the provider-verified email (and its verification status) so the backend can perform + // its own defense-in-depth check of invited-email vs. authenticated-email at accept time. + request.Content = JsonContent.Create(new { subject, identityProvider, email, emailVerified }); HttpResponseMessage response; try @@ -246,6 +312,39 @@ async Task ExchangeInvite(HttpContext context, string invi return InviteExchangeResult.Success; } + /// + /// Determines whether the invite may be exchanged for the given authenticated account, enforcing that + /// the invited email (when the token carries one) matches the account's verified email. + /// + /// The validated invite token. + /// The authenticating account's email. + /// The provider-supplied verification status of . + /// + /// when the invite is not bound to a specific email, or the authenticated + /// verified email matches the invited email; otherwise . + /// + bool IsInvitedEmailBound(string inviteToken, string authenticatedEmail, bool? emailVerified) + { + var emailClaim = config.CurrentValue.Invite?.EmailClaim; + if (string.IsNullOrWhiteSpace(emailClaim) + || !tokenValidator.TryGetClaim(inviteToken, emailClaim, out var invitedEmail) + || string.IsNullOrWhiteSpace(invitedEmail)) + { + // The invite does not target a specific email - there is nothing to bind against. + return true; + } + + // The invite is bound to a specific email, so the account must own that email and the provider + // must not have flagged it as unverified. + if (emailVerified == false) + { + return false; + } + + return !string.IsNullOrWhiteSpace(authenticatedEmail) + && string.Equals(invitedEmail, authenticatedEmail, StringComparison.OrdinalIgnoreCase); + } + bool IsTenantIssuedInvite(string inviteToken, HttpContext context) { var tenantClaim = config.CurrentValue.Invite?.TenantClaim; diff --git a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs index 327a427..45828c7 100644 --- a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs +++ b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs @@ -8,6 +8,12 @@ internal static partial class InviteMiddlewareLogging [LoggerMessage(LogLevel.Warning, "Invite token validation failed for path {Path}")] internal static partial void InviteTokenValidationFailed(this ILogger logger, string path); + [LoggerMessage(LogLevel.Warning, "Invite token failed re-validation at the Phase-2 exchange forward for path {Path} - not forwarding to the exchange endpoint")] + internal static partial void InviteExchangeTokenValidationFailed(this ILogger logger, string path); + + [LoggerMessage(LogLevel.Warning, "Invite exchange rejected because the authenticated account's verified email does not match the invited email for subject {Subject}")] + internal static partial void InviteEmailMismatch(this ILogger logger, string subject); + [LoggerMessage(LogLevel.Warning, "Invite exchange URL is not configured - skipping invite exchange")] internal static partial void InviteExchangeUrlNotConfigured(this ILogger logger); diff --git a/Source/AuthProxy/Pages/invitation-email-mismatch.html b/Source/AuthProxy/Pages/invitation-email-mismatch.html new file mode 100644 index 0000000..7207f6a --- /dev/null +++ b/Source/AuthProxy/Pages/invitation-email-mismatch.html @@ -0,0 +1,22 @@ + + + + + + Wrong Account + + + +
+

Wrong Account

+

This invitation was issued for a different email address than the account you signed in with.

+

Please sign in with the account that matches the invitation, or request a new invitation.

+
+ + diff --git a/Source/AuthProxy/WellKnownPageNames.cs b/Source/AuthProxy/WellKnownPageNames.cs index e904535..fbad7d9 100644 --- a/Source/AuthProxy/WellKnownPageNames.cs +++ b/Source/AuthProxy/WellKnownPageNames.cs @@ -45,6 +45,12 @@ public static class WellKnownPageNames ///
public const string InvitationSubjectAlreadyExists = "invitation-subject-already-exists.html"; + /// + /// The page returned when an authenticated user attempts to accept an invitation with an account + /// whose verified email does not match the email the invitation was issued for. + /// + public const string InvitationEmailMismatch = "invitation-email-mismatch.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