diff --git a/Documentation/configuration/lobby/invitation-for-creating-organization.md b/Documentation/configuration/lobby/invitation-for-creating-organization.md index dc5e07c..119874b 100644 --- a/Documentation/configuration/lobby/invitation-for-creating-organization.md +++ b/Documentation/configuration/lobby/invitation-for-creating-organization.md @@ -15,8 +15,9 @@ frontend to finish onboarding. 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. + - If gateway email binding is enabled (`Invite.EmailClaim` is set) and the token was issued for a + specific email, 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 4139cfa..0ed7e65 100644 --- a/Documentation/configuration/lobby/invitation-to-organization.md +++ b/Documentation/configuration/lobby/invitation-to-organization.md @@ -10,11 +10,11 @@ user can continue directly into the application instead of being sent to the lob 2. AuthProxy validates the token and starts authentication in the same way as any other invite. 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. +4. If `Invite.EmailClaim` is configured (opt-in) and the token carries that claim, AuthProxy binds the + invite to its recipient: 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`, always forwarding the authenticated account's + verified email so the backend can apply its own binding check — whether or not gateway enforcement is on. 6. AuthProxy compares the configured `Invite.TenantClaim` from the token with the resolved tenant for the request. 7. If the tenant IDs match, AuthProxy skips the lobby redirect and continues to the target service. @@ -45,7 +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). | +| `EmailClaim` | `string` | Claim in the invite token that contains the email the invitation was issued for. **Empty by default, which leaves gateway email-binding enforcement off.** Set it (for example to `email`) to require the authenticated account's verified email to match the invited email at the exchange. The verified email is forwarded to the exchange endpoint regardless of this setting. | | `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 61d7058..26f3065 100644 --- a/Documentation/configuration/well-known-pages.md +++ b/Documentation/configuration/well-known-pages.md @@ -22,7 +22,7 @@ condition is detected: | `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 | +| `invitation-email-mismatch.html` | Gateway email binding is enabled (`Invite.EmailClaim`) and the account signed in with has a verified email that does not match the email the invitation was issued for (Phase 2). | 403 | --- @@ -113,11 +113,12 @@ indicating that the authenticated user's subject is already associated with an e ### `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. +Served during Phase 2 (post-login invite exchange) when gateway email binding is enabled — the +`Invite.EmailClaim` claim is configured — the invite token was issued for a specific email address, 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. Email binding is off by default; +see [Invitation to Organization](lobby/invitation-to-organization.md) for how to configure the email +claim. --- 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 index b33efbb..afaf56e 100644 --- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs @@ -25,6 +25,12 @@ public class an_invite_exchange : Specification protected bool _exchangeCalled; protected string _exchangeRequestBody = string.Empty; + /// + /// Gets the invite-token claim that carries the invited email. Empty by default (enforcement off); + /// enforcement specs override this to opt in, matching the production opt-in default. + /// + protected virtual string InviteEmailClaim => string.Empty; + void Establish() { var (privateKey, publicKeyPem) = TokenFixture.GenerateKeyPair(); @@ -38,6 +44,7 @@ void Establish() Issuer = Issuer, Audience = Audience, ExchangeUrl = ExchangeUrl, + EmailClaim = InviteEmailClaim, } }; var optionsMonitor = Substitute.For>(); 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 index c9c48c4..807c715 100644 --- 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 @@ -5,6 +5,8 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_ public class and_a_different_account_email_is_used : given.an_invite_exchange { + protected override string InviteEmailClaim => "email"; + void Establish() { // The account that logged in is not the one the invitation was issued for. 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 index a95a4d6..54f520c 100644 --- 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 @@ -7,6 +7,8 @@ public class and_the_authenticated_email_is_not_verified : given.an_invite_excha { const string InvitedEmail = "invited@example.com"; + protected override string InviteEmailClaim => "email"; + void Establish() { // The email matches, but the provider explicitly reports it as unverified. diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_email_claim_is_not_configured.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_email_claim_is_not_configured.cs new file mode 100644 index 0000000..cc1585a --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_email_claim_is_not_configured.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_the_email_claim_is_not_configured : given.an_invite_exchange +{ + void Establish() + { + // Enforcement is opt-in: with EmailClaim unset (the default), a mismatched account is NOT rejected + // at the gateway - the invite still forwards and the backend decides. This preserves existing behavior. + 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_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_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 index 375c2c1..705cb0a 100644 --- 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 @@ -5,9 +5,11 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_ public class and_the_invite_targets_no_email : given.an_invite_exchange { + protected override string InviteEmailClaim => "email"; + void Establish() { - // Backward compatibility: an invite that carries no email claim has nothing to bind against. + // With enforcement configured, an invite that carries no email claim still has nothing to bind against. GivenAuthenticatedUserWith(new Claim("email", "anyone@example.com"), new Claim("email_verified", "true")); GivenPendingInviteCookie(CreateSignedToken()); } 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 index 91a5ae6..8e99323 100644 --- 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 @@ -5,16 +5,17 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_ public class and_the_verified_email_is_forwarded_to_the_exchange : given.an_invite_exchange { - const string InvitedEmail = "invited@example.com"; + const string AuthenticatedEmail = "invited@example.com"; void Establish() { - GivenAuthenticatedUserWith(new Claim("email", InvitedEmail), new Claim("email_verified", "true")); - GivenPendingInviteCookie(CreateSignedToken(claims: [new Claim("email", InvitedEmail)])); + // No EmailClaim override: enforcement is off (the default). The verified email is still forwarded. + GivenAuthenticatedUserWith(new Claim("email", AuthenticatedEmail), new Claim("email_verified", "true")); + GivenPendingInviteCookie(CreateSignedToken()); } 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_in_the_exchange_body() => _exchangeRequestBody.ShouldContain(AuthenticatedEmail); [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 index b9f0c05..f61dc00 100644 --- 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 @@ -7,6 +7,8 @@ public class and_verified_email_matches : given.an_invite_exchange { const string InvitedEmail = "invited@example.com"; + protected override string InviteEmailClaim => "email"; + void Establish() { GivenAuthenticatedUserWith(new Claim("email", InvitedEmail), new Claim("email_verified", "true")); diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs index 884fea6..dafb02c 100644 --- a/Source/AuthProxy/Configuration/Invite.cs +++ b/Source/AuthProxy/Configuration/Invite.cs @@ -51,13 +51,14 @@ public class Invite /// /// 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). + /// invitation was issued for. Empty by default, which leaves email-binding enforcement off. + /// When set (for example to email) and the validated invite token carries that 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. + /// Regardless of this setting, the authenticated account's verified email is always forwarded + /// to the exchange endpoint so the backend can enforce the binding itself. /// - public string EmailClaim { get; set; } = "email"; + public string EmailClaim { get; set; } = string.Empty; /// /// Gets or sets a value indicating whether the invitation ID from the invite token (jti)