Skip to content
Open
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
54 changes: 48 additions & 6 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

Expand All @@ -53,12 +55,50 @@ jobs:

- name: Upload security spec results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: security-spec-results
path: TestResults
retention-days: 14

frontend-dependency-audit:
name: Frontend dependency audit
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.19.5

# Use the Yarn binary committed by the repository, keep the lockfile immutable, and skip all
# dependency lifecycle scripts. The job has read-only repository permissions and no credentials.
- name: Install dependencies without scripts
working-directory: ./Source/Web
env:
YARN_ENABLE_SCRIPTS: "false"
run: |
test "$(node .yarn/releases/yarn-4.17.1.cjs --version)" = "4.17.1"
node .yarn/releases/yarn-4.17.1.cjs install --immutable --mode=skip-build

# Keep lower-severity findings visible without making them the gate. The following step explicitly
# fails the job when npm reports a HIGH or CRITICAL advisory anywhere in the dependency graph.
- name: Report moderate dependency vulnerabilities
continue-on-error: true
working-directory: ./Source/Web
run: node .yarn/releases/yarn-4.17.1.cjs npm audit --recursive --severity moderate

- name: Block high and critical dependency vulnerabilities
working-directory: ./Source/Web
run: node .yarn/releases/yarn-4.17.1.cjs npm audit --recursive --severity high

vulnerable-dependencies:
name: Vulnerable dependencies
runs-on: ubuntu-latest
Expand All @@ -68,10 +108,12 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

Expand All @@ -96,7 +138,7 @@ jobs:

- name: Upload dependency report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vulnerable-dependency-report
path: vulnerable.txt
Expand Down
15 changes: 14 additions & 1 deletion Documentation/aspire/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,22 @@ authproxy.WithInvite(
| `exchangeUrl` | ✓ | Endpoint called after login to exchange the invite token. |
| `issuer` | – | Expected `iss` claim. Omit to skip issuer validation. |
| `audience` | – | Expected `aud` claim. Omit to skip audience validation. |
| `tenantClaim` | – | Claim that carries the tenant ID for tenant-issued invite detection. |
| `tenantClaim` | – | Claim that carries the tenant ID used in the matching-tenant comparison. |
| `subjectAlreadyExistsUrl` | – | Redirect URL when the exchange endpoint returns HTTP 409. Omit to serve the built-in page. |

`MatchingTenantInvitationDestination` defaults to `InvitationCompletionDestination.ReturnUrl`, preserving the
existing direct-to-service flow for matching-tenant invitations. For platform-level invitations that must continue
through Lobby setup even when the tenant claim matches the resolved tenant, compose this after either `WithInvite`
overload:

```csharp
authproxy.WithMatchingTenantInvitationDestination(InvitationCompletionDestination.Lobby);
```

The method writes `Cratis__AuthProxy__Invite__MatchingTenantInvitationDestination`. It changes only the redirect
after a successful completion; staging, completion, tenant matching, recipient binding, attestations, transactions,
cookies, and sessions are unchanged.

### Binding an invitation to the invited email

By default an invite is a bearer token: any subject who signs in holding it can redeem it. To bind it to the
Expand Down
17 changes: 17 additions & 0 deletions Documentation/configuration/lobby/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ Use the lobby documentation based on the onboarding outcome you need:
- [Registration](registration.md) — let a user start a self-serve registration flow that ends in
organization creation. If the user should join an existing organization, invite them instead.

## Shared invitation routing

AuthProxy compares the validated invitation's configured `TenantClaim` value with the tenant resolved for the
request. That equality is observational routing evidence, not issuer identity: it proves only that both observed
values are equal. It does not prove which tenant issued the invitation, because any authority holding the invitation
signing key can write the claim.

| Observed tenant relation | `MatchingTenantInvitationDestination` | Successful completion destination |
|--------------------------|---------------------------------------|-----------------------------------|
| Matching | `ReturnUrl` (default) | The invitation challenge's return URL. |
| Matching | `Lobby` | `Lobby.Frontend.BaseUrl`, with the invitation ID query parameter when enabled. |
| Nonmatching | Either value | `Lobby.Frontend.BaseUrl` when configured; otherwise the return URL. |
| Unresolved | Either value | `Lobby.Frontend.BaseUrl` when configured; otherwise the return URL. |

The destination setting changes only the matching row. It does not change validation, staging, exchange, recipient
binding, attestations, transaction consumption, cookies, or sessions.

## Shared configuration

All lobby-related settings live under `Cratis:AuthProxy:Invite:Lobby`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ completes the staged transaction with a signed attestation, and then sends the u
6. The same Lobby invitation authority verifies and atomically consumes the transaction and attestation ID. If it
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.
This flow is the right fit when the invited user must create an organization. Configure
`MatchingTenantInvitationDestination` as `Lobby` so the creating-organization journey still selects Lobby when the
invitation tenant claim happens to match the tenant resolved for the request.

## Configuration

Expand All @@ -36,6 +38,7 @@ This flow is the right fit when the invited user is not entering an already-reso
"StageUrl": "https://lobby.example.com/_invite/stage",
"ExchangeUrl": "https://lobby.example.com/_invite/exchange",
"TenantClaim": "tenant_id",
"MatchingTenantInvitationDestination": "Lobby",
"EmailClaim": "email",
"Attestation": {
"Issuer": "https://auth.example.com",
Expand Down Expand Up @@ -67,13 +70,14 @@ This flow is the right fit when the invited user is not entering an already-reso
```

| Property | Type | Description |
|----------|------|-------------|
| ---------- | ------ | ------------- |
| `PublicKeyPem` | `string` | PEM-encoded RSA public key used to verify invite token signatures. |
| `Issuer` | `string` | Expected `iss` claim. Leave empty to skip issuer validation. |
| `Audience` | `string` | Expected `aud` claim. Leave empty to skip audience validation. |
| `StageUrl` | `string` | Absolute URL of the Lobby invitation authority's staging endpoint. |
| `ExchangeUrl` | `string` | Absolute URL of the same Lobby invitation authority's completion endpoint. |
| `TenantClaim` | `string` | Claim containing the tenant that owns the invitation. Required by the signed protocol. |
| `TenantClaim` | `string` | Claim containing the tenant observed for invitation routing. Required by the signed protocol. Equality with the resolved tenant does not identify the invitation issuer. |
| `MatchingTenantInvitationDestination` | `InvitationCompletionDestination` | Set to `Lobby` so a matching tenant still enters the organization-creation journey. |
| `EmailClaim` | `string` | Claim type used by the exclusive email-recipient mode. The signed capability must contain exactly one value of this claim or the immutable provider-binding pair, never both. |
| `Attestation` | `object` | RS256 issuer, audience, active key, private signing-key set, and 10–60-second lifetime used for the two internal calls. |
| `SubjectAlreadyExistsUrl` | `string` | Redirect target when the exchange endpoint returns HTTP 409. Leave empty to serve `invitation-subject-already-exists.html`. |
Expand Down Expand Up @@ -112,12 +116,12 @@ needs the matching public key to validate the signature.
Recommended claims:

| Claim | Description |
|-------|-------------|
| ------- | ------------- |
| `iss` | Issuer. Must match `Invite.Issuer` when configured. |
| `aud` | Audience. Must match `Invite.Audience` when configured. |
| `exp` | Expiry time. Expired tokens are rejected. |
| `jti` | Unique invitation identifier. Required by the signed protocol. |
| `tenant_id` | Tenant that owns the invitation, using the configured `TenantClaim` name. |
| `tenant_id` | Tenant value observed for invitation routing, using the configured `TenantClaim` name. |
| `email` | Exactly one invited address for email-recipient mode, using the configured `EmailClaim` name. |
| `recipient_provider_key` + `recipient_identity_binding` | Exact provider key and 43-character opaque binding for immutable identity mode. Both are required together and `email` must be absent. |

Expand All @@ -126,7 +130,7 @@ Recommended claims:
AuthProxy serves dedicated pages for each invitation error:

| Page file | Condition | HTTP status |
|-----------|-----------|-------------|
| ----------- | ----------- | ------------- |
| `invitation-expired.html` | The token signature is valid, but the `exp` claim is in the past. | 401 |
| `invitation-invalid.html` | The token is malformed or has an invalid signature. | 401 |
| `invitation-select-provider.html` | The token is valid and multiple identity providers are configured. | 200 |
Expand Down
24 changes: 15 additions & 9 deletions Documentation/configuration/lobby/invitation-to-organization.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Invitation to Organization

Use this flow when you invite a user into an organization that already exists. AuthProxy still uses
the standard `/invite/<token>` bootstrap, but the invite token carries tenant information so the
user can continue directly into the application instead of being sent to the lobby.
the standard `/invite/<token>` bootstrap, but a matching invitation tenant claim and the `ReturnUrl`
destination let the user continue directly into the application instead of being sent to Lobby.

## Flow

Expand All @@ -18,12 +18,14 @@ user can continue directly into the application instead of being sent to the lob
of `true`, one provider-derived assurance value, and the authentication-ticket issue time.
6. AuthProxy calls `Invite.ExchangeUrl` with a signed `invite-complete` attestation. The JSON body contains only
the opaque transaction ID; the browser and request body never supply identity authority.
7. AuthProxy compares the configured `Invite.TenantClaim` from the token with the resolved tenant
for the request.
8. If the tenant IDs match, AuthProxy skips the lobby redirect and continues to the target service.
7. AuthProxy compares the configured `Invite.TenantClaim` from the token with the tenant resolved for the request.
Equality is observational routing evidence, not issuer identity: it does not prove which tenant issued the
invitation.
8. If the tenant values match, `Invite.MatchingTenantInvitationDestination` selects `ReturnUrl` or `Lobby`.
`ReturnUrl` is the default 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.
If the tenant values differ, or AuthProxy cannot observe both values, the invitation selects Lobby when its frontend
is configured. The matching-tenant enum does not change those rows of the shared routing matrix.

## Configuration

Expand All @@ -35,6 +37,7 @@ treated like lobby onboarding and falls back to the configured lobby behavior.
"StageUrl": "https://lobby.example.com/_invite/stage",
"ExchangeUrl": "https://lobby.example.com/_invite/exchange",
"TenantClaim": "tenant_id",
"MatchingTenantInvitationDestination": "ReturnUrl",
"EmailClaim": "email",
"Attestation": {
"Issuer": "https://auth.example.com",
Expand All @@ -61,7 +64,8 @@ treated like lobby onboarding and falls back to the configured lobby behavior.
|----------|------|-------------|
| `StageUrl` | `string` | Absolute URL of the Lobby invitation authority's pre-authentication staging endpoint. Required when `Attestation` is configured. |
| `ExchangeUrl` | `string` | Absolute URL of the same Lobby invitation authority's completion endpoint. |
| `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID. |
| `TenantClaim` | `string` | Claim in the invite token that contains the tenant ID observed for routing. Equality with the resolved tenant does not identify the invitation issuer. |
| `MatchingTenantInvitationDestination` | `InvitationCompletionDestination` | Destination for matching tenant values. `ReturnUrl` is the default; use `Lobby` to select `Lobby.Frontend.BaseUrl`. |
| `EmailClaim` | `string` | Claim in the invite token that contains the invited email. Required by the signed protocol. |
| `Attestation.Issuer` | `string` | Exact issuer the invitation authority validates. |
| `Attestation.Audience` | `string` | Exact invitation-authority audience. |
Expand Down Expand Up @@ -130,7 +134,9 @@ identity tuple; `email` and `preferred_username` are not substitutes.

> **Compatibility.** Omitting `Invite.Attestation` retains the released unsigned JSON exchange for existing
> deployments. That legacy mode is not sufficient authority for creating or linking an account. Enable the signed
> protocol before an application treats invitation completion as identity proof.
> protocol before an application treats invitation completion as identity proof. Independently,
> `Invite.MatchingTenantInvitationDestination` defaults to `ReturnUrl`, preserving the released matching-tenant
> redirect behavior.

## Rotate signing keys

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.Aspire.for_AuthProxyExtensions;

public class when_configuring_matching_tenant_invitation_destination_to_lobby : given.an_auth_proxy_resource
{
Dictionary<string, string> _environment;

void Establish() => _resource.WithMatchingTenantInvitationDestination(InvitationCompletionDestination.Lobby);

async Task Because() => _environment = await EnvironmentVariables();

[Fact]
void should_emit_the_enum_string_for_lobby() =>
_environment["Cratis__AuthProxy__Invite__MatchingTenantInvitationDestination"].ShouldEqual("Lobby");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.Aspire.for_AuthProxyExtensions;

public class when_configuring_matching_tenant_invitation_destination_to_return_url : given.an_auth_proxy_resource
{
Dictionary<string, string> _environment;

void Establish() => _resource.WithMatchingTenantInvitationDestination(InvitationCompletionDestination.ReturnUrl);

async Task Because() => _environment = await EnvironmentVariables();

[Fact]
void should_emit_the_enum_string_for_return_url() =>
_environment["Cratis__AuthProxy__Invite__MatchingTenantInvitationDestination"].ShouldEqual("ReturnUrl");
}
35 changes: 33 additions & 2 deletions Source/Aspire/AuthProxyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
/// Adds an AuthProxy container resource to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The resource name (e.g. <c>"authproxy"</c>).</param>

Check warning on line 17 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

Check warning on line 17 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// <param name="tag">
/// Optional Docker image tag. Defaults to <see cref="AuthProxyResource.ContainerImageTag"/> (<c>latest</c>).

Check warning on line 19 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

Check warning on line 19 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// Pin this to a specific release in production (e.g. <c>"1.2.3"</c>).

Check warning on line 20 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

Check warning on line 20 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// </param>
/// <returns>An <see cref="IResourceBuilder{T}"/> for the <see cref="AuthProxyResource"/>.</returns>
public static IResourceBuilder<AuthProxyResource> AddAuthProxy(
Expand All @@ -34,10 +34,10 @@
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="serviceName">
/// The service key used in the AuthProxy <c>Services</c> configuration (e.g. <c>"main"</c>).

Check warning on line 37 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

Check warning on line 37 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// </param>
/// <param name="serviceResource">The Aspire resource that exposes the backend.</param>
/// <param name="endpointName">The endpoint name to use. Defaults to <c>"http"</c>.</param>

Check warning on line 40 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// <param name="resolveIdentityDetails">
/// Whether AuthProxy should call <c>GET {baseUrl}/.cratis/me</c> on this backend to enrich
/// the identity cookie after authentication. Defaults to <see langword="null"/> (AuthProxy uses
Expand Down Expand Up @@ -69,12 +69,12 @@
}

/// <summary>
/// Declares what a named service's <c>/.cratis/me</c> answer means to AuthProxy.

Check warning on line 72 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="serviceName">
/// The service key used in the AuthProxy <c>Services</c> configuration (e.g. <c>"main"</c>).

Check warning on line 77 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

Check warning on line 77 in Source/Aspire/AuthProxyExtensions.cs

View workflow job for this annotation

GitHub Actions / dotnet-build

/// </param>
/// <param name="mode">
/// What the answer is worth. <see cref="IdentityVerificationMode.BestEffort"/> — the default when this
Expand Down Expand Up @@ -957,7 +957,7 @@
/// Expected <c>aud</c> claim value. Leave <see langword="null"/> to skip audience validation.
/// </param>
/// <param name="tenantClaim">
/// Claim in the invite token that carries the tenant ID string (used for tenant-issued invite detection).
/// Claim in the invite token that carries the tenant ID string used for matching-tenant routing.
/// Leave <see langword="null"/> to use the AuthProxy default.
/// </param>
/// <param name="subjectAlreadyExistsUrl">
Expand Down Expand Up @@ -1074,7 +1074,7 @@
/// Expected <c>aud</c> claim value. Leave <see langword="null"/> to skip audience validation.
/// </param>
/// <param name="tenantClaim">
/// Claim in the invite token that carries the tenant ID string (used for tenant-issued invite detection).
/// Claim in the invite token that carries the tenant ID string used for matching-tenant routing.
/// Leave <see langword="null"/> to use the AuthProxy default.
/// </param>
/// <param name="subjectAlreadyExistsUrl">
Expand Down Expand Up @@ -1126,6 +1126,37 @@
return builder;
}

/// <summary>
/// Configures where the browser is redirected after a successfully completed invitation whose tenant claim
/// matches the resolved tenant.
/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="destination">
/// The post-completion destination. <see cref="InvitationCompletionDestination.ReturnUrl"/> preserves the
/// released behavior; <see cref="InvitationCompletionDestination.Lobby"/> redirects matching-tenant invitations
/// to the configured lobby frontend.
/// </param>
/// <returns>The same <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// <para>
/// This setting changes only the redirect after successful invitation completion. It does not change tenant
/// matching, invitation validation, recipient binding, attestations, transactions, cookies, or sessions.
/// </para>
/// <para>
/// Matching-tenant invitations are those where the configured <c>TenantClaim</c> value in the invitation
/// capability equals the tenant resolved for the request. The equality does not prove that the invitation
/// was issued by that tenant — any issuer holding the signing key can write that claim. It proves only
/// that the invitation names the tenant the request is being served for, which is enough to know whether
/// the browser should stay in the tenant's own surface or continue through Lobby.
/// </para>
/// </remarks>
public static IResourceBuilder<T> WithMatchingTenantInvitationDestination<T>(
this IResourceBuilder<T> builder,
InvitationCompletionDestination destination)
where T : IResourceWithEnvironment =>
builder.WithEnvironment($"{ConfigPrefix}__Invite__MatchingTenantInvitationDestination", destination.ToString());

/// <summary>
/// Binds an invitation to the address it was issued to, so only the invited recipient can redeem it.
/// AuthProxy reads <paramref name="emailClaim"/> from the validated invite token and compares it against the
Expand Down
Loading
Loading