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
10 changes: 10 additions & 0 deletions Documentation/configuration/error-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ condition is detected:
| `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 |
| `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 |
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 |

---

Expand Down Expand Up @@ -91,6 +92,15 @@ The built-in page reads the cookie with JavaScript and renders one sign-in butto
You can override it with a custom branded version by placing your own `invitation-select-provider.html`
in the configured pages directory.

### `invitation-subject-already-exists.html`

Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict,
indicating that the authenticated user's subject is already associated with an existing account.
The user should sign in with their existing account rather than completing the invitation again.

If you prefer to redirect users to a custom URL instead of serving this page, configure
`Invite.SubjectAlreadyExistsUrl` (see [Invites & Lobby](invites.md#invite-properties)).

---

## Provider info cookie (`.cratis-providers`)
Expand Down
17 changes: 15 additions & 2 deletions Documentation/configuration/invites.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ redirected while they complete the onboarding process.
1. After a successful OIDC login the user is redirected back.
2. AuthProxy detects the invite cookie, calls the configured `ExchangeUrl` with the token and the
authenticated user's subject, then deletes the cookie.
3. If the exchange succeeds and a **lobby** service is configured, the user is redirected to
3. If the exchange endpoint returns **HTTP 409 Conflict** (subject already registered), AuthProxy
redirects to `SubjectAlreadyExistsUrl` (if configured) or serves the built-in
`invitation-subject-already-exists.html` page.
4. If the exchange succeeds and a **lobby** service is configured, the user is redirected to
the lobby's frontend so they can enter the application with their newly assigned tenant.

### Lobby – no-tenant redirect
Expand Down Expand Up @@ -56,6 +59,7 @@ All invite and lobby settings live under `Cratis:AuthProxy:Invite`:
"Audience": "authproxy",
"ExchangeUrl": "https://studio.example.com/internal/invites/exchange",
"TenantClaim": "tenant_id",
"SubjectAlreadyExistsUrl": "https://app.example.com/errors/account-already-exists",
"AppendInvitationIdToQueryString": true,
"InvitationIdQueryStringKey": "invitationId",
"ClaimsToForward": [
Expand All @@ -81,6 +85,7 @@ All invite and lobby settings live under `Cratis:AuthProxy:Invite`:
| `Audience` | `string` | Expected `aud` claim. Leave empty to skip audience validation. |
| `ExchangeUrl` | `string` | Absolute URL of the invite-exchange endpoint, e.g. `https://studio.example.com/internal/invites/exchange`. |
| `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID string for tenant-issued invite detection. |
| `SubjectAlreadyExistsUrl` | `string` | URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered). Leave empty to serve the built-in `invitation-subject-already-exists.html` page. |
| `AppendInvitationIdToQueryString` | `bool` | Appends `jti` from the invite token to the lobby redirect query string when enabled. |
| `InvitationIdQueryStringKey` | `string` | Query string key used when appending invitation ID. |
| `ClaimsToForward` | `InviteClaimForwarding[]` | Claim mappings forwarded from invite token into the principal sent to identity details providers. |
Expand Down Expand Up @@ -150,8 +155,16 @@ AuthProxy distinguishes between two token failure modes and serves a dedicated p
| `invitation-expired.html` | The token had a valid signature but has passed its `exp` claim. |
| `invitation-invalid.html` | The token is malformed, carries an invalid signature, or cannot be parsed. |
| `invitation-select-provider.html` | The token is valid and multiple identity providers are configured. |
| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account (exchange returned HTTP 409). |

All error pages are served with HTTP 401 except `invitation-select-provider.html` which uses HTTP 200.
All error pages are served with the following HTTP status codes:

| Page | HTTP status |
|------|-------------|
| `invitation-expired.html` | 401 |
| `invitation-invalid.html` | 401 |
| `invitation-select-provider.html` | 200 |
| `invitation-subject-already-exists.html` | 409 |
These pages use full descriptive names rather than numeric error codes because they represent
application-level conditions, not generic HTTP errors.

Expand Down
6 changes: 6 additions & 0 deletions Documentation/configuration/well-known-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ condition is detected:
| `invitation-expired.html` | An invite link was followed but the JWT token has passed its expiry time. | 401 |
| `invitation-invalid.html` | An invite link was followed but the JWT token is malformed or has an invalid signature. | 401 |
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 |

---

Expand Down Expand Up @@ -100,6 +101,11 @@ passed its `exp` claim. The user should request a fresh invitation.
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.

### `invitation-subject-already-exists.html`

Served during Phase 2 (post-login invite exchange) when the exchange endpoint returns HTTP 409 Conflict,
indicating that the authenticated user's subject is already associated with an existing account.

---

## Tenant not found
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Net;

namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_authenticated_user_has_pending_invite;

public class and_exchange_returns_duplicate_subject_status : Specification
{
InviteMiddleware _middleware;
DefaultHttpContext _context;
IErrorPageProvider _errorPageProvider;
bool _nextCalled;

void Establish()
{
var tokenValidator = Substitute.For<IInviteTokenValidator>();

var config = new C.AuthProxy
{
Invite = new C.Invite
{
ExchangeUrl = "http://studio/internal/invites/exchange"
}
};
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 FakeHttpMessageHandler(HttpStatusCode.Conflict)));

_errorPageProvider = Substitute.For<IErrorPageProvider>();

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

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

var identity = new ClaimsIdentity([new Claim("sub", "user-123")], "aad");
_context.User = new ClaimsPrincipal(identity);
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}=pending-invite-token";
}

async Task Because() => await _middleware.InvokeAsync(_context);

[Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse();
[Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken);
[Fact]
void should_serve_subject_already_exists_page() =>
_errorPageProvider.Received(1).WriteErrorPageAsync(
_context,
WellKnownPageNames.InvitationSubjectAlreadyExists,
StatusCodes.Status409Conflict);

static IOptionsMonitor<C.Authentication> CreateEmptyAuthConfig()
{
var monitor = Substitute.For<IOptionsMonitor<C.Authentication>>();
monitor.CurrentValue.Returns(new C.Authentication());
return monitor;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Net;

namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_authenticated_user_has_pending_invite;

public class and_exchange_returns_duplicate_subject_status_with_configured_url : Specification
{
const string SubjectAlreadyExistsUrl = "https://app.example.com/errors/account-already-exists";

InviteMiddleware _middleware;
DefaultHttpContext _context;
IErrorPageProvider _errorPageProvider;
bool _nextCalled;

void Establish()
{
var tokenValidator = Substitute.For<IInviteTokenValidator>();

var config = new C.AuthProxy
{
Invite = new C.Invite
{
ExchangeUrl = "http://studio/internal/invites/exchange",
SubjectAlreadyExistsUrl = SubjectAlreadyExistsUrl
}
};
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 FakeHttpMessageHandler(HttpStatusCode.Conflict)));

_errorPageProvider = Substitute.For<IErrorPageProvider>();

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

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

var identity = new ClaimsIdentity([new Claim("sub", "user-123")], "aad");
_context.User = new ClaimsPrincipal(identity);
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}=pending-invite-token";
}

async Task Because() => await _middleware.InvokeAsync(_context);

[Fact] void should_not_call_next() => _nextCalled.ShouldBeFalse();
[Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken);
[Fact] void should_redirect_to_configured_url() => _context.Response.Headers.Location.ToString().ShouldEqual(SubjectAlreadyExistsUrl);
[Fact] void should_not_serve_error_page() => _errorPageProvider.DidNotReceiveWithAnyArgs().WriteErrorPageAsync(default!, default!, default);

static IOptionsMonitor<C.Authentication> CreateEmptyAuthConfig()
{
var monitor = Substitute.For<IOptionsMonitor<C.Authentication>>();
monitor.CurrentValue.Returns(new C.Authentication());
return monitor;
}
}
9 changes: 9 additions & 0 deletions Source/AuthProxy/Configuration/Invite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public class Invite
/// </summary>
public string ExchangeUrl { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the URL to redirect to when the authenticated user's subject is already
/// associated with an existing user during the invite exchange (Phase 2).
/// When set, the user is redirected to this URL instead of the built-in
/// <c>invitation-subject-already-exists.html</c> page.
/// Leave empty to serve the built-in well-known error page.
/// </summary>
public string SubjectAlreadyExistsUrl { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the claim name in the invite token that holds the tenant ID.
/// When set, a tenant-issued invite is recognized when this claim's value matches
Expand Down
19 changes: 19 additions & 0 deletions Source/AuthProxy/Invites/InviteExchangeResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.AuthProxy.Invites;

/// <summary>
/// Represents the outcome of an invite exchange request.
/// </summary>
enum InviteExchangeResult
{
/// <summary>The exchange completed successfully.</summary>
Success = 0,

/// <summary>The exchange was rejected because the subject is already associated with an existing user (HTTP 409).</summary>
DuplicateSubject = 1,

/// <summary>The exchange failed for any other reason.</summary>
Failed = 2,
}
38 changes: 31 additions & 7 deletions Source/AuthProxy/Invites/InviteMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,28 @@ public async Task InvokeAsync(HttpContext context)
if (context.User.Identity?.IsAuthenticated == true
&& context.TryGetPendingInvitationToken(out var inviteToken))
{
var exchangeSucceeded = await ExchangeInvite(context, inviteToken);
var exchangeResult = await ExchangeInvite(context, inviteToken);
context.Response.Cookies.Delete(Cookies.InviteToken);

if (exchangeSucceeded && !IsTenantIssuedInvite(inviteToken, context))
if (exchangeResult == InviteExchangeResult.DuplicateSubject)
{
var subjectAlreadyExistsUrl = config.CurrentValue.Invite?.SubjectAlreadyExistsUrl;
if (!string.IsNullOrWhiteSpace(subjectAlreadyExistsUrl))
{
context.Response.Redirect(subjectAlreadyExistsUrl);
}
else
{
await errorPageProvider.WriteErrorPageAsync(
context,
WellKnownPageNames.InvitationSubjectAlreadyExists,
StatusCodes.Status409Conflict);
}

return;
}

if (exchangeResult == InviteExchangeResult.Success && !IsTenantIssuedInvite(inviteToken, context))
{
var lobbyUrl = config.CurrentValue.Invite?.Lobby?.Frontend?.BaseUrl;
if (!string.IsNullOrWhiteSpace(lobbyUrl))
Expand Down Expand Up @@ -175,13 +193,13 @@ static IEnumerable<OidcProviderInfo> GetAllProviders(C.Authentication config) =>
config.OidcProviders.Select(OidcProviderScheme.ToProviderInfo)
.Concat(config.OAuthProviders.Select(OidcProviderScheme.ToProviderInfo));

async Task<bool> ExchangeInvite(HttpContext context, string inviteToken)
async Task<InviteExchangeResult> ExchangeInvite(HttpContext context, string inviteToken)
{
var exchangeUrl = config.CurrentValue.Invite?.ExchangeUrl;
if (string.IsNullOrWhiteSpace(exchangeUrl))
{
logger.InviteExchangeUrlNotConfigured();
return false;
return InviteExchangeResult.Failed;
}

var subject = context.User.FindFirst("sub")?.Value
Expand Down Expand Up @@ -209,17 +227,23 @@ async Task<bool> ExchangeInvite(HttpContext context, string inviteToken)
catch (Exception ex)
{
logger.FailedToCallInviteExchangeEndpoint(ex, exchangeUrl);
return false;
return InviteExchangeResult.Failed;
}

if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
logger.InviteSubjectAlreadyExists(subject);
return InviteExchangeResult.DuplicateSubject;
}

if (!response.IsSuccessStatusCode)
{
logger.InviteExchangeEndpointFailed((int)response.StatusCode, subject);
return false;
return InviteExchangeResult.Failed;
}

logger.InviteExchangedSuccessfully(subject);
return true;
return InviteExchangeResult.Success;
}

bool IsTenantIssuedInvite(string inviteToken, HttpContext context)
Expand Down
3 changes: 3 additions & 0 deletions Source/AuthProxy/Invites/InviteMiddlewareLogging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ internal static partial class InviteMiddlewareLogging

[LoggerMessage(LogLevel.Information, "Invite exchanged successfully for subject {Subject}")]
internal static partial void InviteExchangedSuccessfully(this ILogger logger, string subject);

[LoggerMessage(LogLevel.Warning, "Invite exchange rejected because subject {Subject} is already associated with an existing user")]
internal static partial void InviteSubjectAlreadyExists(this ILogger logger, string subject);
}
22 changes: 22 additions & 0 deletions Source/AuthProxy/Pages/invitation-subject-already-exists.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Account Already Exists</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2.5rem 3rem; text-align: center; max-width: 480px; }
h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 .75rem; color: #333; }
p { color: #666; margin: 0 0 1rem; }
p:last-child { margin: 0; }
</style>
</head>
<body>
<div class="card">
<h1>Account Already Exists</h1>
<p>An account is already registered for your identity.</p>
<p>Please sign in with your existing account instead of accepting the invitation again.</p>
</div>
</body>
</html>
6 changes: 6 additions & 0 deletions Source/AuthProxy/WellKnownPageNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public static class WellKnownPageNames
/// </summary>
public const string InvitationInvalid = "invitation-invalid.html";

/// <summary>
/// The page returned when an authenticated user attempts to accept an invitation but the subject
/// is already associated with an existing user.
/// </summary>
public const string InvitationSubjectAlreadyExists = "invitation-subject-already-exists.html";

/// <summary>
/// The page served when a valid invitation token is presented and multiple identity providers
/// are configured. The page reads the <c>.cratis-providers</c> cookie injected by the proxy
Expand Down
Loading