diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 854ee6a..f2f9a0d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 }} @@ -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 @@ -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 }} @@ -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 diff --git a/Documentation/aspire/index.md b/Documentation/aspire/index.md index 179eacf..6f05db9 100644 --- a/Documentation/aspire/index.md +++ b/Documentation/aspire/index.md @@ -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 diff --git a/Documentation/configuration/lobby/index.md b/Documentation/configuration/lobby/index.md index 313fcfd..91d0bfe 100644 --- a/Documentation/configuration/lobby/index.md +++ b/Documentation/configuration/lobby/index.md @@ -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`: diff --git a/Documentation/configuration/lobby/invitation-for-creating-organization.md b/Documentation/configuration/lobby/invitation-for-creating-organization.md index 871375f..0fd3bb2 100644 --- a/Documentation/configuration/lobby/invitation-for-creating-organization.md +++ b/Documentation/configuration/lobby/invitation-for-creating-organization.md @@ -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 @@ -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", @@ -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`. | @@ -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. | @@ -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 | diff --git a/Documentation/configuration/lobby/invitation-to-organization.md b/Documentation/configuration/lobby/invitation-to-organization.md index f5bf040..444739e 100644 --- a/Documentation/configuration/lobby/invitation-to-organization.md +++ b/Documentation/configuration/lobby/invitation-to-organization.md @@ -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/` 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/` 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 @@ -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 @@ -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", @@ -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. | @@ -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 diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_lobby.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_lobby.cs new file mode 100644 index 0000000..f6d9897 --- /dev/null +++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_lobby.cs @@ -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 _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"); +} diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_return_url.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_return_url.cs new file mode 100644 index 0000000..bc9a2a5 --- /dev/null +++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_configuring_matching_tenant_invitation_destination_to_return_url.cs @@ -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 _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"); +} diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs index e99f1cb..deadc50 100644 --- a/Source/Aspire/AuthProxyExtensions.cs +++ b/Source/Aspire/AuthProxyExtensions.cs @@ -957,7 +957,7 @@ public static IResourceBuilder WithTenantVerification( /// Expected aud claim value. Leave to skip audience validation. /// /// - /// 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 to use the AuthProxy default. /// /// @@ -1074,7 +1074,7 @@ public static IResourceBuilder WithSignedInvitationAttestations( /// Expected aud claim value. Leave to skip audience validation. /// /// - /// 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 to use the AuthProxy default. /// /// @@ -1126,6 +1126,37 @@ public static IResourceBuilder WithInvite( return builder; } + /// + /// Configures where the browser is redirected after a successfully completed invitation whose tenant claim + /// matches the resolved tenant. + /// + /// The resource type (must support environment variables). + /// The resource builder. + /// + /// The post-completion destination. preserves the + /// released behavior; redirects matching-tenant invitations + /// to the configured lobby frontend. + /// + /// The same for chaining. + /// + /// + /// 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. + /// + /// + /// Matching-tenant invitations are those where the configured TenantClaim 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. + /// + /// + public static IResourceBuilder WithMatchingTenantInvitationDestination( + this IResourceBuilder builder, + InvitationCompletionDestination destination) + where T : IResourceWithEnvironment => + builder.WithEnvironment($"{ConfigPrefix}__Invite__MatchingTenantInvitationDestination", destination.ToString()); + /// /// Binds an invitation to the address it was issued to, so only the invited recipient can redeem it. /// AuthProxy reads from the validated invite token and compares it against the diff --git a/Source/Aspire/InvitationCompletionDestination.cs b/Source/Aspire/InvitationCompletionDestination.cs new file mode 100644 index 0000000..97667d3 --- /dev/null +++ b/Source/Aspire/InvitationCompletionDestination.cs @@ -0,0 +1,34 @@ +// 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; + +/// +/// Determines where the browser is redirected after a successfully completed invitation whose tenant claim +/// matches the resolved tenant. +/// +/// +/// This setting changes only the post-completion redirect choice. It does not change invitation staging, +/// completion, tenant matching, recipient binding, attestations, transactions, cookies, or sessions. +/// +/// Matching-tenant invitations are those where the configured TenantClaim 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. +/// +/// +public enum InvitationCompletionDestination +{ + /// + /// The browser continues toward the invitation challenge's return URL instead of selecting the configured + /// Lobby frontend. This is the default and preserves the released behavior. + /// + ReturnUrl = 0, + + /// + /// The browser is redirected to the configured Lobby frontend, the same as nonmatching or unresolved + /// invitations. + /// + Lobby = 1, +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/given/an_invite_configuration.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/given/an_invite_configuration.cs new file mode 100644 index 0000000..e612f17 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/given/an_invite_configuration.cs @@ -0,0 +1,10 @@ +// 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_InviteConfigurationValidator.given; + +public class an_invite_configuration : Specification +{ + protected static ValidateOptionsResult Validate(C.AuthProxy configuration) => + new InviteConfigurationValidator().Validate(null, configuration); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_destination_is_undefined_enum_value.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_destination_is_undefined_enum_value.cs new file mode 100644 index 0000000..b96f940 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_destination_is_undefined_enum_value.cs @@ -0,0 +1,26 @@ +// 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_InviteConfigurationValidator; + +public class when_destination_is_undefined_enum_value : given.an_invite_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "https://lobby.example.com/_invite/exchange", + MatchingTenantInvitationDestination = (C.InvitationCompletionDestination)99, + } + }); + + [Fact] + void should_fail() => _result.Failed.ShouldBeTrue(); + + [Fact] + void should_report_the_exact_failure() => + _result.Failures.ShouldContainOnly( + ["Invite.MatchingTenantInvitationDestination has an undefined value '99'. Use 'ReturnUrl' or 'Lobby'."]); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_invite_configuration_is_absent.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_invite_configuration_is_absent.cs new file mode 100644 index 0000000..916a313 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_invite_configuration_is_absent.cs @@ -0,0 +1,14 @@ +// 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_InviteConfigurationValidator; + +public class when_invite_configuration_is_absent : given.an_invite_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(new C.AuthProxy { Invite = null }); + + [Fact] + void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_is_valid.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_is_valid.cs new file mode 100644 index 0000000..19a7e5b --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_is_valid.cs @@ -0,0 +1,26 @@ +// 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_InviteConfigurationValidator; + +public class when_lobby_destination_is_valid : given.an_invite_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "https://lobby.example.com/_invite/exchange", + TenantClaim = "tenant_id", + MatchingTenantInvitationDestination = C.InvitationCompletionDestination.Lobby, + Lobby = new C.Service + { + Frontend = new C.ServiceEndpoint { BaseUrl = "https://lobby.example.com/" } + } + } + }); + + [Fact] + void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_lobby_frontend_url.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_lobby_frontend_url.cs new file mode 100644 index 0000000..aada003 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_lobby_frontend_url.cs @@ -0,0 +1,31 @@ +// 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_InviteConfigurationValidator; + +public class when_lobby_destination_without_lobby_frontend_url : given.an_invite_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "https://lobby.example.com/_invite/exchange", + TenantClaim = "tenant_id", + MatchingTenantInvitationDestination = C.InvitationCompletionDestination.Lobby, + Lobby = null + } + }); + + [Fact] + void should_fail() => _result.Failed.ShouldBeTrue(); + + [Fact] + void should_report_the_exact_failure() => + _result.Failures.ShouldContainOnly( + [ + "Invite.MatchingTenantInvitationDestination is 'Lobby' but Invite.Lobby.Frontend.BaseUrl is not configured. " + + "A Lobby frontend URL is required when matching-tenant invitations redirect to Lobby." + ]); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_tenant_claim.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_tenant_claim.cs new file mode 100644 index 0000000..6e3fd53 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_lobby_destination_without_tenant_claim.cs @@ -0,0 +1,34 @@ +// 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_InviteConfigurationValidator; + +public class when_lobby_destination_without_tenant_claim : given.an_invite_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(new C.AuthProxy + { + Invite = new C.Invite + { + ExchangeUrl = "https://lobby.example.com/_invite/exchange", + TenantClaim = string.Empty, + MatchingTenantInvitationDestination = C.InvitationCompletionDestination.Lobby, + Lobby = new C.Service + { + Frontend = new C.ServiceEndpoint { BaseUrl = "https://lobby.example.com/" } + } + } + }); + + [Fact] + void should_fail() => _result.Failed.ShouldBeTrue(); + + [Fact] + void should_report_the_exact_failure() => + _result.Failures.ShouldContainOnly( + [ + "Invite.MatchingTenantInvitationDestination is 'Lobby' but Invite.TenantClaim is not configured. " + + "A tenant claim is required to determine which invitations match the resolved tenant." + ]); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_using_default_return_url_destination.cs b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_using_default_return_url_destination.cs new file mode 100644 index 0000000..a9c0e83 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteConfigurationValidator/when_using_default_return_url_destination.cs @@ -0,0 +1,24 @@ +// 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_InviteConfigurationValidator; + +public class when_using_default_return_url_destination : given.an_invite_configuration +{ + C.Invite _invite; + ValidateOptionsResult _result; + + void Establish() => _invite = new C.Invite + { + ExchangeUrl = "https://lobby.example.com/_invite/exchange" + }; + + void Because() => _result = Validate(new C.AuthProxy { Invite = _invite }); + + [Fact] + void should_default_to_return_url() => + _invite.MatchingTenantInvitationDestination.ShouldEqual(C.InvitationCompletionDestination.ReturnUrl); + + [Fact] + void should_succeed() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_default_return_url_destination.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_default_return_url_destination.cs new file mode 100644 index 0000000..0ca1062 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_default_return_url_destination.cs @@ -0,0 +1,85 @@ +// 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_tenant_invite; + +public class and_tenant_matches_with_default_return_url_destination : Specification +{ + const string LobbyUrl = "http://lobby-service/"; + const string TenantClaimType = "tenant_id"; + const string TenantId = "tenant-matched"; + + InviteMiddleware _middleware; + C.Invite _invite; + DefaultHttpContext _context; + bool _nextCalled; + + void Establish() + { + var tokenValidator = Substitute.For(); + tokenValidator.TryGetClaim(Arg.Any(), TenantClaimType, out Arg.Any()) + .Returns(x => + { + x[2] = TenantId; + return true; + }); + + _invite = new C.Invite + { + ExchangeUrl = "http://studio/internal/invites/exchange", + TenantClaim = TenantClaimType, + Lobby = new C.Service + { + Frontend = new C.ServiceEndpoint { BaseUrl = LobbyUrl } + } + }; + var config = new C.AuthProxy { Invite = _invite }; + var optionsMonitor = Substitute.For>(); + optionsMonitor.CurrentValue.Returns(config); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns( + new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.OK))); + + _middleware = new InviteMiddleware( + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }, + tokenValidator, + optionsMonitor, + CreateEmptyAuthConfig(), + Substitute.For(), + httpClientFactory, + Substitute.For(), + Substitute.For>()); + + _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"; + InvitationSessionFixture.GivenSessionEstablishedByTheInvitation(_context, "pending-invite-token"); + _context.Items[TenancyMiddleware.TenantIdItemKey] = TenantId; + } + + async Task Because() => await _middleware.InvokeAsync(_context); + + [Fact] void should_use_return_url_as_the_enum_default() => _invite.MatchingTenantInvitationDestination.ShouldEqual(C.InvitationCompletionDestination.ReturnUrl); + [Fact] void should_call_next() => _nextCalled.ShouldBeTrue(); + [Fact] void should_not_redirect_to_lobby() => _context.Items.ContainsKey(InviteMiddleware.LobbyRedirectUrlItemKey).ShouldBeFalse(); + [Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); + + static IOptionsMonitor CreateEmptyAuthConfig() + { + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(new C.Authentication()); + return monitor; + } +} diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_destination_lobby.cs similarity index 90% rename from Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches.cs rename to Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_destination_lobby.cs index 9214219..993e97a 100644 --- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches.cs +++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_authenticated_user_has_pending_tenant_invite/and_tenant_matches_with_destination_lobby.cs @@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_authenticated_user_has_pending_tenant_invite; -public class and_tenant_matches : Specification +public class and_tenant_matches_with_destination_lobby : Specification { const string LobbyUrl = "http://lobby-service/"; const string TenantClaimType = "tenant_id"; @@ -31,6 +31,7 @@ void Establish() { ExchangeUrl = "http://studio/internal/invites/exchange", TenantClaim = TenantClaimType, + MatchingTenantInvitationDestination = C.InvitationCompletionDestination.Lobby, Lobby = new C.Service { Frontend = new C.ServiceEndpoint { BaseUrl = LobbyUrl } @@ -73,7 +74,7 @@ void Establish() async Task Because() => await _middleware.InvokeAsync(_context); [Fact] void should_call_next() => _nextCalled.ShouldBeTrue(); - [Fact] void should_not_redirect_to_lobby() => _context.Response.Headers.Location.ToString().ShouldNotContain(LobbyUrl); + [Fact] void should_set_lobby_redirect_url_in_context_items() => _context.Items[InviteMiddleware.LobbyRedirectUrlItemKey].ShouldEqual(LobbyUrl); [Fact] void should_delete_invite_cookie() => _context.Response.Headers.SetCookie.ToString().ShouldContain(Cookies.InviteToken); static IOptionsMonitor CreateEmptyAuthConfig() diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/CallbackAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/CallbackAuthProxyFactory.cs index 8eb50bb..499b200 100644 --- a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/CallbackAuthProxyFactory.cs +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/CallbackAuthProxyFactory.cs @@ -27,7 +27,8 @@ public class CallbackAuthProxyFactory : WebApplicationFactory { public const string ProviderName = "TestIdp"; public const string ProviderScheme = "testidp"; - public const string ExchangeUrl = "http://exchange.test/invites/exchange"; + public const string StageUrl = "https://exchange.test/invites/stage"; + public const string ExchangeUrl = "https://exchange.test/invites/exchange"; public const string IdentityBackendBaseUrl = "http://identity.test/"; public const string LobbyUrl = "http://lobby.test/"; public const string SubjectAlreadyExistsUrl = "http://lobby.test/already-a-member"; @@ -53,6 +54,13 @@ public CallbackAuthProxyFactory() public int ExchangeCallCount => _exchangeCallCount; + /// + /// Gets the configured matching-tenant invitation destination observed by the running application. + /// + public C.InvitationCompletionDestination MatchingTenantInvitationDestination => + Services.GetRequiredService>().CurrentValue.Invite?.MatchingTenantInvitationDestination + ?? C.InvitationCompletionDestination.ReturnUrl; + /// Gets or sets the status the faked invitation exchange endpoint answers with. public HttpStatusCode ExchangeStatusCode { get; set; } = HttpStatusCode.OK; @@ -155,6 +163,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { var url = request.RequestUri?.ToString() ?? string.Empty; + if (url.StartsWith(StageUrl, StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.OK); + } + if (url.StartsWith(ExchangeUrl, StringComparison.OrdinalIgnoreCase)) { Interlocked.Increment(ref _exchangeCallCount); diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/MatchingTenantLobbyRedirectCallbackAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/MatchingTenantLobbyRedirectCallbackAuthProxyFactory.cs new file mode 100644 index 0000000..74783e1 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/MatchingTenantLobbyRedirectCallbackAuthProxyFactory.cs @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using Microsoft.AspNetCore.Hosting; + +namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback; + +/// +/// Configures callback scenarios with signed invitation attestations and the matching-tenant destination set to +/// Lobby. +/// +public sealed class MatchingTenantLobbyRedirectCallbackAuthProxyFactory : CallbackAuthProxyFactory +{ + const string AttestationKeyId = "callback-spec-key"; + readonly string _attestationPrivateKeyPem = CreatePrivateKeyPem(); + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + foreach (var (key, value) in new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Invite:MatchingTenantInvitationDestination"] = nameof(C.InvitationCompletionDestination.Lobby), + [$"{C.AuthProxy.SectionKey}:Invite:StageUrl"] = StageUrl, + [$"{C.AuthProxy.SectionKey}:Invite:EmailClaim"] = "email", + [$"{C.AuthProxy.SectionKey}:Invite:Attestation:Issuer"] = "https://authproxy.test", + [$"{C.AuthProxy.SectionKey}:Invite:Attestation:Audience"] = "callback-spec", + [$"{C.AuthProxy.SectionKey}:Invite:Attestation:ActiveKeyId"] = AttestationKeyId, + [$"{C.AuthProxy.SectionKey}:Invite:Attestation:SigningKeys:0:KeyId"] = AttestationKeyId, + [$"{C.AuthProxy.SectionKey}:Invite:Attestation:SigningKeys:0:PrivateKeyPem"] = _attestationPrivateKeyPem, + [$"{C.Authentication.SectionKey}:OAuthProviders:0:CanonicalIdentity:InvitationCompletionEnabled"] = bool.TrueString, + [$"{C.Authentication.SectionKey}:OAuthProviders:0:CanonicalIdentity:ProviderKey"] = "testidp", + [$"{C.Authentication.SectionKey}:OAuthProviders:0:CanonicalIdentity:SubjectClaimType"] = "sub", + [$"{C.Authentication.SectionKey}:OAuthProviders:0:CanonicalIdentity:Issuer"] = "https://identity.test", + }) + { + builder.UseSetting(key, value); + } + } + + static string CreatePrivateKeyPem() + { + using var rsa = RSA.Create(2048); + return rsa.ExportPkcs8PrivateKeyPem(); + } +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_lobby.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_lobby.cs new file mode 100644 index 0000000..07b5529 --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_lobby.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites; + +namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback; + +/// +/// End-to-end scenario: a signed matching-tenant invitation completes once on the provider callback and establishes +/// one session, while the Lobby destination policy sends the browser to Lobby instead of replaying the invite URL. +/// +/// The signed invitation callback factory with matching-tenant destination set to Lobby. +public class and_the_matching_tenant_destination_is_lobby(MatchingTenantLobbyRedirectCallbackAuthProxyFactory factory) : + IClassFixture, IAsyncLifetime +{ + string _invitationId; + CallbackAuthProxyFactory.ProviderSignIn _signIn; + int _exchangeCallsDuringFlow; + + public async Task InitializeAsync() + { + _invitationId = Guid.NewGuid().ToString(); + var token = TokenFixture.CreateToken( + factory.InviteKeyPair.PrivateKey, + additionalClaims: + [ + new Claim("jti", _invitationId), + new Claim(CallbackAuthProxyFactory.TenantClaim, CallbackAuthProxyFactory.TenantId), + new Claim("email", "invitee@example.com"), + ]); + + using var browser = factory.CreateBrowser(); + var exchangeCallsBefore = factory.ExchangeCallCount; + _signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}"); + _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void should_stage_the_signed_invitation() => + Assert.Contains( + _signIn.ChallengeCookies, + cookie => cookie.StartsWith($"{Cookies.InvitationEntryState}=", StringComparison.Ordinal)); + + [Fact] + public void should_call_the_attested_completion_endpoint_once() => + Assert.Equal(1, _exchangeCallsDuringFlow); + + [Fact] + public void should_establish_one_session() => + Assert.Single( + _signIn.CallbackCookies, + cookie => cookie.StartsWith(CallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal)); + + [Fact] + public void should_redirect_the_callback_to_lobby() => + Assert.Equal( + $"{CallbackAuthProxyFactory.LobbyUrl}?invitationId={_invitationId}", + _signIn.Callback.Headers.Location?.ToString()); +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_invitation_is_tenant_issued.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_return_url.cs similarity index 77% rename from Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_invitation_is_tenant_issued.cs rename to Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_return_url.cs index 2b6bc35..e5e69e0 100644 --- a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_invitation_is_tenant_issued.cs +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_destination_is_return_url.cs @@ -6,12 +6,12 @@ namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback; /// -/// End-to-end scenario: the invitation was issued by the tenant the request is served for, so a completed -/// invitation does not leave for the lobby — the exchange still runs on the callback, but the browser is -/// redirected to the challenge's own return URL exactly as any other sign-in would be. +/// End-to-end scenario: the invitation tenant claim matches the tenant resolved for the request and the default +/// destination is ReturnUrl, so the exchange runs on the callback and the browser continues toward the challenge's +/// own return URL. /// /// The shared application factory. -public class and_the_invitation_is_tenant_issued(CallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime +public class and_the_matching_tenant_destination_is_return_url(CallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime { string _token; CallbackAuthProxyFactory.ProviderSignIn _signIn; @@ -35,6 +35,10 @@ public async Task InitializeAsync() public Task DisposeAsync() => Task.CompletedTask; + [Fact] + public void should_use_return_url_as_the_enum_default() => + Assert.Equal(C.InvitationCompletionDestination.ReturnUrl, factory.MatchingTenantInvitationDestination); + [Fact] public void should_call_the_exchange_endpoint_on_the_callback() => Assert.Equal(1, _exchangeCallsDuringFlow); diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_lobby_completion_is_replayed.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_lobby_completion_is_replayed.cs new file mode 100644 index 0000000..61864fb --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_lobby_completion_is_replayed.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites; + +namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback; + +/// +/// End-to-end scenario: a matching-tenant invitation already completed toward Lobby, and the browser replays the +/// invitation URL with the stale pending cookie. +/// +/// The signed invitation factory with the matching-tenant destination set to Lobby. +public class and_the_matching_tenant_lobby_completion_is_replayed(MatchingTenantLobbyRedirectCallbackAuthProxyFactory factory) : + IClassFixture, IAsyncLifetime +{ + string _invitationId; + HttpResponseMessage _replay; + int _exchangeCallsDuringFlow; + + public async Task InitializeAsync() + { + _invitationId = Guid.NewGuid().ToString(); + var token = TokenFixture.CreateToken( + factory.InviteKeyPair.PrivateKey, + additionalClaims: + [ + new Claim("jti", _invitationId), + new Claim(CallbackAuthProxyFactory.TenantClaim, CallbackAuthProxyFactory.TenantId), + new Claim("email", "invitee@example.com"), + ]); + + using var browser = factory.CreateBrowser(); + var exchangeCallsBefore = factory.ExchangeCallCount; + var signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}"); + + using var replayRequest = new HttpRequestMessage(HttpMethod.Get, $"/invite/{token}"); + replayRequest.Headers.Add( + "Cookie", + string.Join("; ", signIn.CallbackCookies.Append($"{Cookies.InviteToken}={token}"))); + _replay = await browser.SendAsync(replayRequest); + _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void should_exchange_only_once() => + Assert.Equal(1, _exchangeCallsDuringFlow); + + [Fact] + public void should_redirect_the_replay_to_lobby_with_the_invitation_id() => + Assert.Equal( + $"{CallbackAuthProxyFactory.LobbyUrl}?invitationId={_invitationId}", + _replay.Headers.Location?.ToString()); + + [Fact] + public void should_clear_the_stale_pending_invitation_cookie() + { + _replay.Headers.TryGetValues("Set-Cookie", out var cookies); + Assert.Contains( + cookies ?? [], + cookie => cookie.StartsWith($"{Cookies.InviteToken}=;", StringComparison.Ordinal)); + } +} diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_return_url_completion_is_replayed.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_return_url_completion_is_replayed.cs new file mode 100644 index 0000000..176e66c --- /dev/null +++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_matching_tenant_return_url_completion_is_replayed.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites; + +namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback; + +/// +/// End-to-end scenario: a matching-tenant invitation already completed toward ReturnUrl, and the browser replays +/// the invitation URL with the stale pending cookie. +/// +/// The shared application factory using the default ReturnUrl destination. +public class and_the_matching_tenant_return_url_completion_is_replayed(CallbackAuthProxyFactory factory) : + IClassFixture, IAsyncLifetime +{ + HttpResponseMessage _replay; + int _exchangeCallsDuringFlow; + + public async Task InitializeAsync() + { + var token = TokenFixture.CreateToken( + factory.InviteKeyPair.PrivateKey, + additionalClaims: + [ + new Claim("jti", Guid.NewGuid().ToString()), + new Claim(CallbackAuthProxyFactory.TenantClaim, CallbackAuthProxyFactory.TenantId), + ]); + + using var browser = factory.CreateBrowser(); + var exchangeCallsBefore = factory.ExchangeCallCount; + var signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}"); + + using var replayRequest = new HttpRequestMessage(HttpMethod.Get, $"/invite/{token}"); + replayRequest.Headers.Add( + "Cookie", + string.Join("; ", signIn.CallbackCookies.Append($"{Cookies.InviteToken}={token}"))); + _replay = await browser.SendAsync(replayRequest); + _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public void should_exchange_only_once() => + Assert.Equal(1, _exchangeCallsDuringFlow); + + [Fact] + public void should_not_redirect_the_replay_to_lobby() => + Assert.False( + _replay.Headers.Location?.ToString().StartsWith(CallbackAuthProxyFactory.LobbyUrl, StringComparison.Ordinal) ?? false); + + [Fact] + public void should_clear_the_stale_pending_invitation_cookie() + { + _replay.Headers.TryGetValues("Set-Cookie", out var cookies); + Assert.Contains( + cookies ?? [], + cookie => cookie.StartsWith($"{Cookies.InviteToken}=;", StringComparison.Ordinal)); + } +} diff --git a/Source/AuthProxy/Configuration/InvitationCompletionDestination.cs b/Source/AuthProxy/Configuration/InvitationCompletionDestination.cs new file mode 100644 index 0000000..8da9dc8 --- /dev/null +++ b/Source/AuthProxy/Configuration/InvitationCompletionDestination.cs @@ -0,0 +1,34 @@ +// 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.Configuration; + +/// +/// Determines where the browser is redirected after a successfully completed invitation whose tenant claim +/// matches the resolved tenant. +/// +/// +/// This setting changes only the post-completion redirect choice. It does not change invitation staging, +/// completion, tenant matching, recipient binding, attestations, transactions, cookies, or sessions. +/// +/// Matching-tenant invitations are those where the configured 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. +/// +/// +public enum InvitationCompletionDestination +{ + /// + /// The browser continues toward the invitation challenge's return URL instead of selecting the configured + /// Lobby frontend. This is the default and preserves the released behavior. + /// + ReturnUrl = 0, + + /// + /// The browser is redirected to the configured frontend, the same as + /// nonmatching or unresolved invitations. + /// + Lobby = 1, +} diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs index 56238d8..32f148e 100644 --- a/Source/AuthProxy/Configuration/Invite.cs +++ b/Source/AuthProxy/Configuration/Invite.cs @@ -61,13 +61,37 @@ public class Invite /// /// 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 - /// the resolved tenant. If they match the invite bypasses the lobby redirect and - /// the user proceeds directly to the microservice. - /// Leave empty to disable tenant-issued invite detection. + /// When set, AuthProxy compares this claim with the tenant resolved for the request. + /// controls the destination when they match. + /// Leave empty when invitation routing does not observe a tenant relation. /// public string TenantClaim { get; set; } = string.Empty; + /// + /// Gets or sets where the browser is redirected after a successfully completed invitation whose + /// value matches the resolved tenant. + /// + /// + /// + /// Defaults to to preserve the released behavior + /// and let the browser continue toward the invitation challenge's return URL. Set to + /// to redirect matching-tenant invitations to + /// like other successfully completed invitations. + /// + /// + /// This setting changes only the post-completion redirect choice. It does not change invitation staging, + /// completion, tenant matching, recipient binding, attestations, transactions, cookies, or sessions. + /// + /// + /// Matching-tenant invitations are those where the configured 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. + /// + /// + public InvitationCompletionDestination MatchingTenantInvitationDestination { get; set; } = InvitationCompletionDestination.ReturnUrl; + /// /// Gets or sets the claim name in the invite token that holds the email address for which the invitation /// was issued. An empty value disables AuthProxy's email-binding enforcement. When configured and present diff --git a/Source/AuthProxy/Invites/IInviteCompletion.cs b/Source/AuthProxy/Invites/IInviteCompletion.cs index 3c980e3..3208bd3 100644 --- a/Source/AuthProxy/Invites/IInviteCompletion.cs +++ b/Source/AuthProxy/Invites/IInviteCompletion.cs @@ -39,14 +39,14 @@ interface IInviteCompletion Task ExchangeForTicket(HttpContext context, string inviteToken, ClaimsPrincipal principal, AuthenticationProperties properties); /// - /// Resolves where a successfully completed, non-tenant-issued invitation should take the browser. + /// Resolves whether a successfully completed invitation should take the browser to the configured lobby. /// /// The current . /// The completed invitation capability. /// The lobby frontend URL, with the invitation id appended when configured. /// - /// when the invitation is not tenant-issued and a lobby frontend is configured; - /// otherwise , meaning the browser stays on its current course. + /// when the selected destination is Lobby and a lobby frontend is configured; + /// otherwise , meaning the browser continues toward its return URL. /// bool TryResolveLobbyRedirect(HttpContext context, string inviteToken, out string lobbyRedirectUrl); } diff --git a/Source/AuthProxy/Invites/InvitationTenantRelation.cs b/Source/AuthProxy/Invites/InvitationTenantRelation.cs new file mode 100644 index 0000000..b776ebb --- /dev/null +++ b/Source/AuthProxy/Invites/InvitationTenantRelation.cs @@ -0,0 +1,26 @@ +// 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; + +/// +/// Describes the observed relation between an invitation's configured tenant claim and the tenant resolved for +/// the current request. +/// +internal enum InvitationTenantRelation +{ + /// + /// One or both tenant values could not be resolved, so their relation is unknown. + /// + Unresolved = 0, + + /// + /// Both tenant values were resolved and are equal. + /// + Matching = 1, + + /// + /// Both tenant values were resolved and are different. + /// + NonMatching = 2, +} diff --git a/Source/AuthProxy/Invites/InviteCallbackCompletion.cs b/Source/AuthProxy/Invites/InviteCallbackCompletion.cs index 226a5ab..d09a4c8 100644 --- a/Source/AuthProxy/Invites/InviteCallbackCompletion.cs +++ b/Source/AuthProxy/Invites/InviteCallbackCompletion.cs @@ -110,8 +110,8 @@ static InviteCallbackCompletionResult CompleteSuccessfully( return InviteCallbackCompletionResult.CompletedWithRedirect; } - // Tenant-issued (or no lobby configured): the challenge's own return URL stands, and the handler's - // normal post-sign-in redirect resolution still applies to it. + // ReturnUrl, or an unavailable Lobby destination, keeps the challenge's own return URL and lets the + // handler's normal post-sign-in redirect resolution apply. return InviteCallbackCompletionResult.CompletedTowardReturnUrl; } diff --git a/Source/AuthProxy/Invites/InviteCompletion.cs b/Source/AuthProxy/Invites/InviteCompletion.cs index 4261e3c..a226d65 100644 --- a/Source/AuthProxy/Invites/InviteCompletion.cs +++ b/Source/AuthProxy/Invites/InviteCompletion.cs @@ -82,18 +82,26 @@ public async Task ExchangeForTicket(HttpContext context, s public bool TryResolveLobbyRedirect(HttpContext context, string inviteToken, out string lobbyRedirectUrl) { lobbyRedirectUrl = string.Empty; - if (IsTenantIssuedInvite(inviteToken, context)) + var invite = config.CurrentValue.Invite; + var tenantRelation = ResolveInvitationTenantRelation(inviteToken, context); + var destination = tenantRelation == InvitationTenantRelation.Matching + ? invite?.MatchingTenantInvitationDestination ?? C.InvitationCompletionDestination.ReturnUrl + : C.InvitationCompletionDestination.Lobby; + + if (destination == C.InvitationCompletionDestination.ReturnUrl) { + logger.InvitationCompletionDestinationSelected(destination, tenantRelation); return false; } - var lobbyUrl = config.CurrentValue.Invite?.Lobby?.Frontend?.BaseUrl; + var lobbyUrl = invite?.Lobby?.Frontend?.BaseUrl; if (string.IsNullOrWhiteSpace(lobbyUrl)) { return false; } lobbyRedirectUrl = BuildLobbyRedirectUrlWithInvitationId(lobbyUrl, inviteToken); + logger.InvitationCompletionDestinationSelected(destination, tenantRelation); return true; } @@ -454,30 +462,31 @@ InviteExchangeResult EvaluateInvitedEmailBinding(string inviteToken, string auth : InviteExchangeResult.EmailMismatch; } - bool IsTenantIssuedInvite(string inviteToken, HttpContext context) + /// + /// Resolves the observed relation between the invitation's configured tenant claim and the tenant resolved + /// for the request. + /// + /// The validated invitation capability. + /// The current . + /// The observed tenant relation. + /// + /// Equality is observational routing evidence, not issuer identity. Any issuer holding the signing key can + /// write the tenant claim. A match proves only that the invitation names the tenant serving the request. + /// + InvitationTenantRelation ResolveInvitationTenantRelation(string inviteToken, HttpContext context) { var tenantClaim = config.CurrentValue.Invite?.TenantClaim; - if (string.IsNullOrEmpty(tenantClaim)) - { - return false; - } - - if (!tokenValidator.TryGetClaim(inviteToken, tenantClaim, out var tokenTenantIdStr)) - { - return false; - } - - if (string.IsNullOrWhiteSpace(tokenTenantIdStr)) + if (string.IsNullOrEmpty(tenantClaim) + || !tokenValidator.TryGetClaim(inviteToken, tenantClaim, out var tokenTenantId) + || string.IsNullOrWhiteSpace(tokenTenantId) + || !TryResolveTenant(context, out var resolvedTenantId)) { - return false; - } - - if (!TryResolveTenant(context, out var resolvedTenantId)) - { - return false; + return InvitationTenantRelation.Unresolved; } - return string.Equals(tokenTenantIdStr, resolvedTenantId, StringComparison.OrdinalIgnoreCase); + return string.Equals(tokenTenantId, resolvedTenantId, StringComparison.OrdinalIgnoreCase) + ? InvitationTenantRelation.Matching + : InvitationTenantRelation.NonMatching; } /// diff --git a/Source/AuthProxy/Invites/InviteConfigurationValidator.cs b/Source/AuthProxy/Invites/InviteConfigurationValidator.cs new file mode 100644 index 0000000..0ab228a --- /dev/null +++ b/Source/AuthProxy/Invites/InviteConfigurationValidator.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.Invites; + +/// +/// Validates the invite configuration settings that control post-completion redirect behavior. +/// +sealed class InviteConfigurationValidator : IValidateOptions +{ + /// + /// Validates one AuthProxy configuration instance. + /// + /// The options instance name. + /// The configuration to validate. + /// All configuration failures, or a successful validation result. + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var invite = options.Invite; + if (invite is null) + { + return ValidateOptionsResult.Success; + } + + var failures = new List(); + var destination = invite.MatchingTenantInvitationDestination; + + // Reject undefined enum values that may have been parsed from configuration. + if (!Enum.IsDefined(destination)) + { + failures.Add( + $"Invite.MatchingTenantInvitationDestination has an undefined value '{(int)destination}'. " + + $"Use '{nameof(C.InvitationCompletionDestination.ReturnUrl)}' or '{nameof(C.InvitationCompletionDestination.Lobby)}'."); + } + + // Lobby destination requires TenantClaim to be set. + if (destination == C.InvitationCompletionDestination.Lobby + && string.IsNullOrWhiteSpace(invite.TenantClaim)) + { + failures.Add( + "Invite.MatchingTenantInvitationDestination is 'Lobby' but Invite.TenantClaim is not configured. " + + "A tenant claim is required to determine which invitations match the resolved tenant."); + } + + // Lobby destination requires Lobby.Frontend.BaseUrl to be set. + if (destination == C.InvitationCompletionDestination.Lobby + && string.IsNullOrWhiteSpace(invite.Lobby?.Frontend?.BaseUrl)) + { + failures.Add( + "Invite.MatchingTenantInvitationDestination is 'Lobby' but Invite.Lobby.Frontend.BaseUrl is not configured. " + + "A Lobby frontend URL is required when matching-tenant invitations redirect to Lobby."); + } + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } +} diff --git a/Source/AuthProxy/Invites/InviteMiddleware.cs b/Source/AuthProxy/Invites/InviteMiddleware.cs index ce52167..a1bbe1f 100644 --- a/Source/AuthProxy/Invites/InviteMiddleware.cs +++ b/Source/AuthProxy/Invites/InviteMiddleware.cs @@ -73,8 +73,8 @@ public class InviteMiddleware( /// /// Key used to store the post-exchange lobby redirect URL in . - /// Set by Phase 2 when exchange succeeds and the invite is not tenant-issued. - /// Read by to perform the actual redirect. + /// Set after a current or prior successful invitation completion only when Lobby is the selected destination + /// and its frontend is configured. Read by to perform the redirect. /// /// /// This mechanism belongs to the middleware pipeline alone. When an invitation completes on the provider @@ -585,8 +585,8 @@ await errorPageProvider.WriteErrorPageAsync( /// /// Continues a request whose session has already completed the invitation it presents: the stale pending - /// state is cleared and the caller is taken where the completed invitation leads — the lobby for a - /// non-tenant-issued invitation, the pipeline's own course otherwise. + /// state is cleared, and a lobby redirect is signaled only when Lobby is the selected destination and its + /// frontend is configured; otherwise, the pipeline continues its own course. /// /// The current . /// The already-completed invitation capability. diff --git a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs index b60ebc4..bd7d8a4 100644 --- a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs +++ b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs @@ -34,4 +34,10 @@ internal static partial class InviteMiddlewareLogging [LoggerMessage(LogLevel.Information, "Invitation not completed because the authenticated session was not established by this invitation's own challenge - taking the caller through provider selection instead")] internal static partial void InviteSessionWasNotEstablishedByTheInvitation(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Invitation completion destination selected: {Destination} (tenant relation: {TenantRelation})")] + internal static partial void InvitationCompletionDestinationSelected( + this ILogger logger, + Configuration.InvitationCompletionDestination destination, + InvitationTenantRelation tenantRelation); } diff --git a/Source/AuthProxy/Invites/InvitesServiceCollectionExtensions.cs b/Source/AuthProxy/Invites/InvitesServiceCollectionExtensions.cs index 1247f8d..e89ffb7 100644 --- a/Source/AuthProxy/Invites/InvitesServiceCollectionExtensions.cs +++ b/Source/AuthProxy/Invites/InvitesServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static WebApplicationBuilder AddInvites(this WebApplicationBuilder builde builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton, InvitationAttestationConfigurationValidator>(); + builder.Services.AddSingleton, InviteConfigurationValidator>(); builder.Services.AddSingleton(sp => new InviteCompletion( sp.GetRequiredService(), sp.GetRequiredService>(), diff --git a/Source/Web/package.json b/Source/Web/package.json index 5fdb53b..14f59a3 100644 --- a/Source/Web/package.json +++ b/Source/Web/package.json @@ -33,5 +33,8 @@ "typescript-for-eslint": "npm:typescript@6.0.3", "vite": "^7.3.1" }, - "packageManager": "yarn@4.17.1" + "packageManager": "yarn@4.17.1", + "resolutions": { + "nanoid": "^3.3.18" + } } diff --git a/Source/Web/yarn.lock b/Source/Web/yarn.lock index 4d84e20..550f969 100644 --- a/Source/Web/yarn.lock +++ b/Source/Web/yarn.lock @@ -2412,12 +2412,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.16": - version: 3.3.17 - resolution: "nanoid@npm:3.3.17" +"nanoid@npm:^3.3.18": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" bin: nanoid: bin/nanoid.cjs - checksum: 10/54c3238ba6ea31c173ccf70922481814892075dbf1b4636abec249d0b34d5594fc9a8892c04872068674010a11fa3d18316dc06e59e700def131ff9d8e740426 + checksum: 10/1b3b4fdac831b92b56d1dbe8b1e63a372432faf86ea383134e3b53141ef8108a60b7f84343b5cefecac9550bb74edf8164da93ea07d1c4f757039bc75d8a5d9e languageName: node linkType: hard