Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions Documentation/configuration/lobby/invitation-to-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions Documentation/configuration/well-known-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public class an_invite_exchange : Specification
protected bool _exchangeCalled;
protected string _exchangeRequestBody = string.Empty;

/// <summary>
/// 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.
/// </summary>
protected virtual string InviteEmailClaim => string.Empty;

void Establish()
{
var (privateKey, publicKeyPem) = TokenFixture.GenerateKeyPair();
Expand All @@ -38,6 +44,7 @@ void Establish()
Issuer = Issuer,
Audience = Audience,
ExchangeUrl = ExchangeUrl,
EmailClaim = InviteEmailClaim,
}
};
var optionsMonitor = Substitute.For<IOptionsMonitor<C.AuthProxy>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HttpContext>(), Arg.Any<string>(), Arg.Any<int>());
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
13 changes: 7 additions & 6 deletions Source/AuthProxy/Configuration/Invite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ public class Invite

/// <summary>
/// Gets or sets the claim name in the invite token that holds the email address the
/// invitation was issued for. Defaults to <c>email</c>.
/// 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 <c>email</c>) 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.
/// </summary>
public string EmailClaim { get; set; } = "email";
public string EmailClaim { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether the invitation ID from the invite token (<c>jti</c>)
Expand Down
Loading