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 @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions Documentation/configuration/lobby/invitation-to-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token>`.
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.
Expand All @@ -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/" }
}
Expand All @@ -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
Expand Down
24 changes: 18 additions & 6 deletions Documentation/configuration/well-known-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -94,19 +95,30 @@ they represent application-level conditions, not generic HTTP errors.

### `invitation-expired.html`

Served when a user follows an `/invite/<token>` 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/<token>` 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/<token>` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reusable context that drives <see cref="InviteMiddleware"/> through its Phase-2 (post-login)
/// invite exchange using a <em>real</em> <see cref="InviteTokenValidator"/> 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 <c>Establish</c>, then invoke the middleware.
/// </summary>
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<IOptionsMonitor<C.AuthProxy>>();
optionsMonitor.CurrentValue.Returns(config);

var httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(new CapturingHandler(this)));

_errorPageProvider = Substitute.For<IErrorPageProvider>();
_errorPageProvider
.WriteErrorPageAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.CompletedTask);

_middleware = new InviteMiddleware(
_ =>
{
_nextCalled = true;
return Task.CompletedTask;
},
new InviteTokenValidator(optionsMonitor),
optionsMonitor,
EmptyAuthConfig(),
Substitute.For<ITenantResolver>(),
httpClientFactory,
_errorPageProvider,
Substitute.For<ILogger<InviteMiddleware>>());

_context = new DefaultHttpContext();
_context.Request.Path = "/";
}

/// <summary>
/// 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.
/// </summary>
/// <param name="signingKey">The key to sign with; defaults to the configured (trusted) key.</param>
/// <param name="issuer">The <c>iss</c> claim; defaults to the expected issuer.</param>
/// <param name="audience">The <c>aud</c> claim; defaults to the expected audience.</param>
/// <param name="expires">The expiry time; defaults to one hour from now.</param>
/// <param name="notBefore">The earliest valid time; defaults to one minute ago.</param>
/// <param name="claims">Additional claims to embed in the token.</param>
/// <returns>The compact-serialized signed JWT.</returns>
protected string CreateSignedToken(
RsaSecurityKey? signingKey = null,
string issuer = Issuer,
string audience = Audience,
DateTime? expires = null,
DateTime? notBefore = null,
IEnumerable<Claim>? claims = null) =>
TokenFixture.CreateToken(signingKey ?? _signingKey, issuer, audience, expires, notBefore, claims);

/// <summary>
/// Marks the request as authenticated, always carrying a <c>sub</c> claim plus any supplied claims.
/// </summary>
/// <param name="claims">Additional claims (e.g. <c>email</c>, <c>email_verified</c>) for the account.</param>
protected void GivenAuthenticatedUserWith(params Claim[] claims) =>
_context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.Prepend(new Claim("sub", "user-123")), "aad"));

/// <summary>
/// Places the given token in the pending invite cookie, as it would arrive on the Phase-2 request.
/// </summary>
/// <param name="token">The token to carry in the <c>.cratis-invite</c> cookie.</param>
protected void GivenPendingInviteCookie(string token) =>
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}={token}";

static IOptionsMonitor<C.Authentication> EmptyAuthConfig()
{
var monitor = Substitute.For<IOptionsMonitor<C.Authentication>>();
monitor.CurrentValue.Returns(new C.Authentication());
return monitor;
}

sealed class CapturingHandler(an_invite_exchange owner) : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> 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);
}
}
}
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_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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<HttpContext>(), Arg.Any<string>(), Arg.Any<int>());
}
Original file line number Diff line number Diff line change
@@ -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");
}
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_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<HttpContext>(), Arg.Any<string>(), Arg.Any<int>());
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading