diff --git a/Documentation/configuration/error-pages.md b/Documentation/configuration/error-pages.md
index 9dc97ed..10afe5d 100644
--- a/Documentation/configuration/error-pages.md
+++ b/Documentation/configuration/error-pages.md
@@ -21,7 +21,7 @@ condition is detected:
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 |
| `invitation-email-unavailable.html` | Email binding is enabled, but the identity provider supplied no authenticated-session email address during invite exchange (Phase 2). | 403 |
-| `invitation-email-mismatch.html` | Email binding is enabled, and the identity provider supplied another address or explicitly reported `email_verified=false` during invite exchange (Phase 2). | 403 |
+| `invitation-email-mismatch.html` | Email binding is enabled, and the identity provider supplied another address or did not report the address as verified during invite exchange (Phase 2). | 403 |
---
@@ -115,9 +115,14 @@ that exposes an address.
### `invitation-email-mismatch.html`
-Served during Phase 2 when the provider supplied an address different from the invited address, or explicitly
-reported `email_verified=false`. An absent `email_verified` claim is not universal proof of ownership and is
-forwarded as `null`; OAuth providers do not currently map that claim. See
+Served during Phase 2 when the provider supplied an address different from the invited address, or did not
+report the address as verified.
+
+What counts as unverified depends on the mode. In the legacy unsigned exchange (no `Invite.Attestation`) only
+an explicit `email_verified=false` mismatches; an absent claim is forwarded as `null` for the backend to judge,
+and OAuth providers do not currently map that claim. With `Invite.Attestation` configured, the completion fails
+closed: only a single `email_verified` claim parsing to exactly `true` counts as verified, so a missing,
+duplicated, malformed, or non-`true` claim is answered with this page too. See
[Invitation to Organization](lobby/invitation-to-organization.md) for the complete binding behavior.
---
diff --git a/Documentation/configuration/well-known-pages.md b/Documentation/configuration/well-known-pages.md
index 7734893..f6dc90b 100644
--- a/Documentation/configuration/well-known-pages.md
+++ b/Documentation/configuration/well-known-pages.md
@@ -24,7 +24,7 @@ condition is detected:
| `invitation-select-provider.html` | A valid invite link was followed and multiple identity providers are configured. The page reads the `.cratis-providers` cookie to render a sign-in button for each available provider. | 200 |
| `invitation-subject-already-exists.html` | The authenticated user's subject is already associated with an existing account during invite exchange (Phase 2). | 409 |
| `invitation-email-unavailable.html` | Gateway email binding is enabled (`Invite.EmailClaim`), but the identity provider supplied no authenticated-session email address (Phase 2). | 403 |
-| `invitation-email-mismatch.html` | Gateway email binding is enabled (`Invite.EmailClaim`), and the identity provider supplied another address or explicitly reported `email_verified=false` (Phase 2). | 403 |
+| `invitation-email-mismatch.html` | Gateway email binding is enabled (`Invite.EmailClaim`), and the identity provider supplied another address or did not report the address as verified (Phase 2). With `Invite.Attestation` configured, a missing, duplicated, malformed or non-`true` `email_verified` claim counts as not verified. | 403 |
| `link-select-provider.html` | The embeddable provider-selection page of the [credential-link flow](link.md), served at `/.cratis/link`. Lists providers, opens the chosen provider's link challenge in a top-level window, and reports the outcome to its embedding parent. | 200 |
| `link-complete.html` | A credential link completed, at `/.cratis/link/complete`. Broadcasts the completion on the link flow's `BroadcastChannel` and closes its window. | 200 |
| `link-failed.html` | A credential link did not complete — the provider round-trip failed or the exchange was refused (see [Credential Linking](link.md)). Broadcasts the failure so an embedding selection page can offer a retry. | 403 |
@@ -122,9 +122,15 @@ indicating that the authenticated user's subject is already associated with an e
Served during Phase 2 (post-login invite exchange) when gateway email binding is enabled — the
`Invite.EmailClaim` claim is configured — the invite token was issued for a specific email address — and
-the provider supplied another address or explicitly reported `email_verified=false`. This is distinct from
+the provider supplied another address or did not report the address as verified. This is distinct from
a provider that supplied no address at all.
+What counts as unverified depends on the mode. In the legacy unsigned exchange (no `Invite.Attestation`) only
+an explicit `email_verified=false` mismatches, and an absent claim is forwarded as `null` for the backend to
+judge. With `Invite.Attestation` configured, completion fails closed: only a single `email_verified` claim
+parsing to exactly `true` counts as verified, so a missing, duplicated, malformed or non-`true` claim is
+answered with this page as well.
+
### `invitation-email-unavailable.html`
Served during Phase 2 when gateway email binding is enabled and the identity provider supplied no
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_anonymous_paths/and_they_are_declared_across_several_calls.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_anonymous_paths/and_they_are_declared_across_several_calls.cs
index f0b7690..2ad51dc 100644
--- a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_anonymous_paths/and_they_are_declared_across_several_calls.cs
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_anonymous_paths/and_they_are_declared_across_several_calls.cs
@@ -12,8 +12,8 @@ namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions.when_declaring_anonymo
/// way to write an app host, which is what makes this worth pinning.
///
///
-/// Two services are used to show the count is kept per service: other starts at its own zero rather
-/// than continuing main's numbering.
+/// Two services are used to show the count is kept per service: other starts at its own zero rather
+/// than continuing main's numbering.
///
///
public class and_they_are_declared_across_several_calls : given.an_auth_proxy_resource
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_every_setting_is_given.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_every_setting_is_given.cs
index 694a06f..f6b0880 100644
--- a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_every_setting_is_given.cs
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_every_setting_is_given.cs
@@ -6,7 +6,7 @@ namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions.when_declaring_capabil
///
/// Every setting a deployment overrides reaches the proxy in the form its configuration binder reads —
/// notably the lifetime, which binds as a and therefore has to be written in the
-/// invariant hh:mm:ss form rather than in whatever the host's culture would produce.
+/// invariant hh:mm:ss form rather than in whatever the host's culture would produce.
///
public class and_every_setting_is_given : given.an_auth_proxy_resource
{
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_only_the_verifier_is_given.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_only_the_verifier_is_given.cs
index 77222b4..4763161 100644
--- a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_only_the_verifier_is_given.cs
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_capability_only_admission/and_only_the_verifier_is_given.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions.when_declaring_capabil
///
/// Closing the interactive contract is its own builder rather than another optional argument on
-/// AddAuthProxy, and naming only the verifier writes every key the proxy reads — with the same
+/// AddAuthProxy, and naming only the verifier writes every key the proxy reads — with the same
/// defaults the proxy itself would have used.
///
/// The Aspire package cannot reference the proxy it configures, so these strings are the only thing joining
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_identity_verification.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_identity_verification.cs
index 91d1151..ab24602 100644
--- a/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_identity_verification.cs
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_declaring_identity_verification.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions;
///
/// Declaring what a service's identity answer is worth is its own builder rather than another optional
-/// argument on WithBackend.
+/// argument on WithBackend.
///
/// An optional argument is baked into the call site when the app host is compiled, so adding one to a
/// shipped method changes its signature and every app host already built against the package fails to bind
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_the_management_listener.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_the_management_listener.cs
index ff6ce3e..7b52a66 100644
--- a/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_the_management_listener.cs
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_enabling_the_management_listener.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions;
///
/// Opening the management listener is its own builder rather than another optional argument on
-/// AddAuthProxy, and it writes exactly the four keys the proxy reads.
+/// AddAuthProxy, and it writes exactly the four keys the proxy reads.
///
/// The Aspire package cannot reference the proxy it configures, so these strings are the only thing joining
/// the two. A rename on either side binds nothing and falls back to a default — which for the port, the one
diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs
index deadc50..5175eb3 100644
--- a/Source/Aspire/AuthProxyExtensions.cs
+++ b/Source/Aspire/AuthProxyExtensions.cs
@@ -14,10 +14,10 @@ public static class AuthProxyExtensions
/// Adds an AuthProxy container resource to the application model.
///
/// The .
- /// The resource name (e.g. "authproxy").
+ /// The resource name (e.g. "authproxy").
///
- /// Optional Docker image tag. Defaults to (latest).
- /// Pin this to a specific release in production (e.g. "1.2.3").
+ /// Optional Docker image tag. Defaults to (latest).
+ /// Pin this to a specific release in production (e.g. "1.2.3").
///
/// An for the .
public static IResourceBuilder AddAuthProxy(
@@ -34,12 +34,12 @@ public static IResourceBuilder AddAuthProxy(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The service key used in the AuthProxy Services configuration (e.g. "main").
+ /// The service key used in the AuthProxy Services configuration (e.g. "main").
///
/// The Aspire resource that exposes the backend.
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
///
- /// Whether AuthProxy should call GET {baseUrl}/.cratis/me on this backend to enrich
+ /// Whether AuthProxy should call GET {baseUrl}/.cratis/me on this backend to enrich
/// the identity cookie after authentication. Defaults to (AuthProxy uses
/// its own default — when a backend URL is present).
/// Set to explicitly to opt this service out of identity enrichment.
@@ -69,16 +69,16 @@ public static IResourceBuilder WithBackend(
}
///
- /// Declares what a named service's /.cratis/me answer means to AuthProxy.
+ /// Declares what a named service's /.cratis/me answer means to AuthProxy.
///
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The service key used in the AuthProxy Services configuration (e.g. "main").
+ /// The service key used in the AuthProxy Services configuration (e.g. "main").
///
///
/// What the answer is worth. — the default when this
- /// is never called — treats the endpoint as enrichment, so only an explicit 403 denies.
+ /// is never called — treats the endpoint as enrichment, so only an explicit 403 denies.
/// treats it as an authorization decision, so only an
/// explicit positive admits and every failure to obtain one denies.
///
@@ -94,7 +94,7 @@ public static IResourceBuilder WithBackend(
/// the method's signature and every already-built app host would fail to bind against the new package
/// until it is rebuilt.
///
- /// This is orthogonal to resolveIdentityDetails on WithBackend, which decides whether the
+ /// This is orthogonal to resolveIdentityDetails on WithBackend, which decides whether the
/// endpoint is called at all. Opting a service out of identity resolution and then requiring
/// verification of it asks for a decision from a service that is never consulted, so the service simply
/// does not take part.
@@ -153,10 +153,10 @@ public static IResourceBuilder WithSessionTerminationOnIdentityDenial(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The service key used in the AuthProxy Services configuration (e.g. "main").
+ /// The service key used in the AuthProxy Services configuration (e.g. "main").
///
/// The Aspire resource that exposes the frontend.
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithFrontend(
this IResourceBuilder builder,
@@ -177,19 +177,19 @@ public static IResourceBuilder WithFrontend(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The service key used in the AuthProxy Services configuration (e.g. "main").
+ /// The service key used in the AuthProxy Services configuration (e.g. "main").
///
///
/// The path prefixes to serve anonymously. Each must be a rooted path of literal segments
- /// (e.g. /portal), and is matched case-insensitively on segment boundaries — /portal
- /// covers /portal and /portal/anything, but not /portalx. Anything else is
+ /// (e.g. /portal), and is matched case-insensitively on segment boundaries — /portal
+ /// covers /portal and /portal/anything, but not /portalx. Anything else is
/// discarded by AuthProxy, leaving that path authenticated.
///
/// The same for chaining.
///
/// Use this for paths an application genuinely serves without a session — a magic-link landing page, a
/// signed-token report, a public webhook receiver. Without it those paths are unreachable: an
- /// unauthenticated caller is answered with the provider-selection page instead, at HTTP 200, so
+ /// unauthenticated caller is answered with the provider-selection page instead, at HTTP 200, so
/// a webhook or other non-browser caller records success and never retries.
///
/// Each entry is a prefix and covers everything under it, so name the specific leaf path whenever a
@@ -235,11 +235,11 @@ public static IResourceBuilder WithAnonymousPaths(
/// authority on what a capability means.
///
///
- /// The one path a capability may be presented on. Defaults to /.cratis/admission. Every other
+ /// The one path a capability may be presented on. Defaults to /.cratis/admission. Every other
/// path — and this path with anything below it — answers the same refusal as everything else.
///
///
- /// The largest capability, in bytes, AuthProxy will read. Defaults to 4096.
+ /// The largest capability, in bytes, AuthProxy will read. Defaults to 4096.
///
///
/// How long an admitted browser stays admitted. Defaults to twenty minutes — it bounds an interactive
@@ -254,7 +254,7 @@ public static IResourceBuilder WithAnonymousPaths(
/// is right for a deployment meant to be found and wrong for one whose existence is not meant to be
/// discoverable, and this is the switch between the two.
///
- /// Cannot be combined with WithInvite: two capability mechanisms in one deployment is
+ /// Cannot be combined with WithInvite: two capability mechanisms in one deployment is
/// a misconfiguration, and AuthProxy refuses the combination at startup rather than silently ordering
/// them.
///
@@ -301,12 +301,12 @@ public static IResourceBuilder WithCapabilityOnlyAdmission(
/// traffic on.
///
///
- /// The address it binds. Defaults to 127.0.0.1, which keeps it reachable from within the
+ /// The address it binds. Defaults to 127.0.0.1, which keeps it reachable from within the
/// container and from nowhere else. Widening it publishes the endpoints to everything that can route
/// to the address.
///
- /// The path answering liveness. Defaults to /health/live.
- /// The path answering readiness. Defaults to /health/ready.
+ /// The path answering liveness. Defaults to /health/live.
+ /// The path answering readiness. Defaults to /health/ready.
/// The same for chaining.
/// Thrown when is not a port number.
///
@@ -317,7 +317,7 @@ public static IResourceBuilder WithCapabilityOnlyAdmission(
/// key ring that encrypts every session cookie and issued token.
///
/// The endpoints live only on this listener. They are answered on no other port, they are never added
- /// to the reverse-proxy route table, and a service that serves its own /health keeps serving it.
+ /// to the reverse-proxy route table, and a service that serves its own /health keeps serving it.
///
///
/// Deliberately its own method rather than another optional parameter on
@@ -356,17 +356,17 @@ public static IResourceBuilder WithManagementListener(
/// The resource builder.
///
/// The addresses and ranges of the infrastructure directly in front of AuthProxy — an ingress
- /// controller, load balancer, service mesh sidecar, or CDN egress range. Write a peer as 10.0.0.7
- /// or 2001:db8::1, and a range as 10.0.0.0/8 or 2001:db8::/32.
+ /// controller, load balancer, service mesh sidecar, or CDN egress range. Write a peer as 10.0.0.7
+ /// or 2001:db8::1, and a range as 10.0.0.0/8 or 2001:db8::/32.
///
/// The same for chaining.
/// Thrown when an entry is neither an address nor a CIDR range.
///
- /// X-Forwarded-For and X-Forwarded-Proto are ordinary request headers, so any caller that
+ /// X-Forwarded-For and X-Forwarded-Proto are ordinary request headers, so any caller that
/// can open a connection to AuthProxy can send them. Until this is declared, AuthProxy believes all of
/// them: the address recorded against every sign-in is whatever the caller wrote, and a spoofed
- /// X-Forwarded-Proto: https makes an unencrypted request look encrypted, which is what decides
- /// whether the session cookies carry Secure.
+ /// X-Forwarded-Proto: https makes an unencrypted request look encrypted, which is what decides
+ /// whether the session cookies carry Secure.
///
/// Declare the peers rather than the clients. AuthProxy matches the address it accepted the connection
/// from, which in a container deployment is the ingress, never a browser.
@@ -405,8 +405,8 @@ public static IResourceBuilder WithTrustedProxies(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The number of X-Forwarded-For entries consumed from the right. Defaults in AuthProxy to
- /// 1 — an ingress controller on its own. A CDN in front of a load balancer is 2.
+ /// The number of X-Forwarded-For entries consumed from the right. Defaults in AuthProxy to
+ /// 1 — an ingress controller on its own. A CDN in front of a load balancer is 2.
///
/// The same for chaining.
///
@@ -435,7 +435,7 @@ public static IResourceBuilder WithForwardLimit(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The claim type the caller must carry, for example urn:github:organization or roles.
+ /// The claim type the caller must carry, for example urn:github:organization or roles.
///
///
/// The values that satisfy it. Pass none to require only that the claim is present. Values are
@@ -472,7 +472,7 @@ public static IResourceBuilder WithRequiredClaim(
/// The resource type (must support environment variables).
/// The resource builder.
///
- /// The service key used in the AuthProxy Services configuration (e.g. "main").
+ /// The service key used in the AuthProxy Services configuration (e.g. "main").
///
/// The claim type the caller must carry.
///
@@ -506,13 +506,13 @@ public static IResourceBuilder WithRequiredClaimForService(
///
/// The resource type (must support environment variables).
/// The resource builder.
- /// The display name shown on the login page (e.g. "Contoso AD").
+ /// The display name shown on the login page (e.g. "Contoso AD").
/// The provider brand / type. Used by the login UI to choose the correct logo.
/// The OIDC authority / issuer URL.
/// The OAuth client ID.
/// The OAuth client secret.
///
- /// Optional extra OAuth scopes to request in addition to openid profile email.
+ /// Optional extra OAuth scopes to request in addition to openid profile email.
///
/// The same for chaining.
public static IResourceBuilder WithOidcProvider(
@@ -584,7 +584,7 @@ public static IResourceBuilder WithCanonicalOidcProvider(
///
/// The resource type (must support environment variables).
/// The resource builder.
- /// The display name shown on the login page (e.g. "GitHub").
+ /// The display name shown on the login page (e.g. "GitHub").
/// The provider brand / type.
/// The OAuth 2.0 authorization endpoint URL.
/// The OAuth 2.0 token endpoint URL.
@@ -671,7 +671,7 @@ public static IResourceBuilder WithOAuthProvider(
/// Optional additional OAuth scopes.
///
/// Optional mappings whose key is the resulting principal claim type and whose value is the raw user-information
- /// JSON field. For example, { ["sub"] = "id" } maps a raw id field to the sub claim selected by
+ /// JSON field. For example, { ["sub"] = "id" } maps a raw id field to the sub claim selected by
/// .
///
/// Optional static parameters included in every provider authorization request.
@@ -714,7 +714,7 @@ public static IResourceBuilder WithCanonicalOAuthProvider(
///
/// Adds a host-name-based tenant resolution strategy to AuthProxy.
- /// The resolved host is matched against the Domains list of each configured tenant.
+ /// The resolved host is matched against the Domains list of each configured tenant.
///
/// The resource type (must support environment variables).
/// The resource builder.
@@ -726,7 +726,7 @@ public static IResourceBuilder WithHostTenantResolution(this IResourceBuil
///
/// Adds a sub-host-based tenant resolution strategy to AuthProxy.
/// The tenant ID is derived from the first subdomain label of the request host by convention
- /// (e.g. acme.example.com → acme).
+ /// (e.g. acme.example.com → acme).
///
/// The resource type (must support environment variables).
/// The resource builder.
@@ -772,7 +772,7 @@ public static IResourceBuilder WithClaimTenantResolution(
/// The resource builder.
///
/// A regular expression with a named capture group whose value becomes the tenant source identifier.
- /// Example: ^/(?<tenant>[^/]+)/.
+ /// Example: ^/(?<tenant>[^/]+)/.
///
/// The same for chaining.
public static IResourceBuilder WithRouteTenantResolution(
@@ -841,7 +841,7 @@ public static IResourceBuilder WithDefaultTenantResolution(
/// The resource builder.
///
/// Absolute URL of the endpoint that returns selectable tenants for the current authenticated user.
- /// Expected response shape is an array of { "id": "...", "name": "..." } objects.
+ /// Expected response shape is an array of { "id": "...", "name": "..." } objects.
/// When the endpoint is omitted and must be supplied via other configuration.
///
/// The same for chaining.
@@ -872,9 +872,9 @@ public static IResourceBuilder WithSelectionTenantResolution(
/// The Aspire resource that hosts the selectable-tenants endpoint.
///
/// The route on the service that returns the selectable tenant list,
- /// e.g. "/api/tenants/selectable".
+ /// e.g. "/api/tenants/selectable".
///
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithSelectionTenantResolution(
this IResourceBuilder builder,
@@ -903,9 +903,9 @@ public static IResourceBuilder WithSelectionTenantResolution(
/// The resource builder.
///
/// A URL template used to check whether a tenant exists.
- /// Use {tenantId} as a placeholder for the resolved tenant identifier,
- /// e.g. https://platform.example.com/api/tenants/{tenantId}.
- /// An HTTP GET to the resolved URL must return 200 when the tenant exists and 404 when it does not.
+ /// Use {tenantId} as a placeholder for the resolved tenant identifier,
+ /// e.g. https://platform.example.com/api/tenants/{tenantId}.
+ /// An HTTP GET to the resolved URL must return 200 when the tenant exists and 404 when it does not.
///
/// The same for chaining.
public static IResourceBuilder WithTenantVerification(
@@ -922,10 +922,10 @@ public static IResourceBuilder WithTenantVerification(
/// The resource builder.
/// The Aspire resource that hosts the tenant-verification endpoint.
///
- /// The route on the service, including the {tenantId} placeholder,
- /// e.g. "/api/tenants/{tenantId}".
+ /// The route on the service, including the {tenantId} placeholder,
+ /// e.g. "/api/tenants/{tenantId}".
///
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithTenantVerification(
this IResourceBuilder builder,
@@ -948,13 +948,13 @@ public static IResourceBuilder WithTenantVerification(
/// PEM-encoded RSA public key used to verify invite token signatures.
///
/// Absolute URL of the invite-exchange endpoint called after a successful login with a pending invite token,
- /// e.g. https://lobby.example.com/_invite/exchange.
+ /// e.g. https://lobby.example.com/_invite/exchange.
///
///
- /// Expected iss claim value. Leave to skip issuer validation.
+ /// Expected iss claim value. Leave to skip issuer validation.
///
///
- /// Expected aud claim value. Leave to skip audience validation.
+ /// Expected aud claim value. Leave to skip audience validation.
///
///
/// Claim in the invite token that carries the tenant ID string used for matching-tenant routing.
@@ -962,7 +962,7 @@ public static IResourceBuilder WithTenantVerification(
///
///
/// URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered).
- /// Leave to serve the built-in invitation-subject-already-exists.html page.
+ /// Leave to serve the built-in invitation-subject-already-exists.html page.
///
/// The same for chaining.
public static IResourceBuilder WithInvite(
@@ -1023,7 +1023,7 @@ public static IResourceBuilder WithInvite(
///
/// Signed attestations require recipient binding: also call
/// with a non-empty claim, and pass a
- /// tenantClaim to WithInvite. Without both, AuthProxy fails options validation at startup.
+ /// tenantClaim to WithInvite. Without both, AuthProxy fails options validation at startup.
///
///
public static IResourceBuilder WithSignedInvitationAttestations(
@@ -1064,14 +1064,14 @@ public static IResourceBuilder WithSignedInvitationAttestations(
/// PEM-encoded RSA public key used to verify invite token signatures.
/// The Aspire resource that hosts the invite-exchange endpoint.
///
- /// The route on the exchange service, e.g. "/internal/invites/exchange".
+ /// The route on the exchange service, e.g. "/internal/invites/exchange".
///
- /// The endpoint name to use for the exchange service. Defaults to "http".
+ /// The endpoint name to use for the exchange service. Defaults to "http".
///
- /// Expected iss claim value. Leave to skip issuer validation.
+ /// Expected iss claim value. Leave to skip issuer validation.
///
///
- /// Expected aud claim value. Leave to skip audience validation.
+ /// Expected aud claim value. Leave to skip audience validation.
///
///
/// Claim in the invite token that carries the tenant ID string used for matching-tenant routing.
@@ -1079,7 +1079,7 @@ public static IResourceBuilder WithSignedInvitationAttestations(
///
///
/// URL to redirect to when the exchange endpoint returns HTTP 409 (subject already registered).
- /// Leave to serve the built-in invitation-subject-already-exists.html page.
+ /// Leave to serve the built-in invitation-subject-already-exists.html page.
///
/// The same for chaining.
public static IResourceBuilder WithInvite(
@@ -1144,7 +1144,7 @@ public static IResourceBuilder WithInvite(
/// matching, invitation validation, recipient binding, attestations, transactions, cookies, or sessions.
///
///
- /// Matching-tenant invitations are those where the configured TenantClaim value in the invitation
+ /// 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
@@ -1170,10 +1170,10 @@ public static IResourceBuilder WithMatchingTenantInvitationDestination(
///
/// The same for chaining.
///
- /// Compose this after either WithInvite overload. When the provider offers no usable address, AuthProxy
- /// rejects the invite with invitation-email-unavailable.html; when the address differs from the invited
- /// one — or the provider explicitly reports email_verified=false — it rejects with
- /// invitation-email-mismatch.html.
+ /// Compose this after either WithInvite overload. When the provider offers no usable address, AuthProxy
+ /// rejects the invite with invitation-email-unavailable.html; when the address differs from the invited
+ /// one — or the provider explicitly reports email_verified=false — it rejects with
+ /// invitation-email-mismatch.html.
///
/// Not calling this method, or passing an empty claim, writes nothing and retains the released default of no
/// recipient binding: any authenticated subject holding the invite token can redeem it.
@@ -1195,7 +1195,7 @@ public static IResourceBuilder WithInviteEmailBinding(
///
/// Adds a claim-forwarding entry to the AuthProxy invite system.
/// When a pending invite cookie exists, AuthProxy reads the specified claim from the invite token
- /// and forwards it as part of the principal sent to each /.cratis/me identity details endpoint.
+ /// and forwards it as part of the principal sent to each /.cratis/me identity details endpoint.
/// Call this method once per claim to forward; multiple calls accumulate entries.
///
/// The resource type (must support environment variables).
@@ -1233,7 +1233,7 @@ public static IResourceBuilder WithInviteClaimForwarding(
/// The resource type (must support environment variables).
/// The resource builder.
/// The Aspire resource that exposes the lobby frontend.
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithLobbyFrontend(
this IResourceBuilder builder,
@@ -1255,7 +1255,7 @@ public static IResourceBuilder WithLobbyFrontend(
/// The resource type (must support environment variables).
/// The resource builder.
/// The Aspire resource that exposes the lobby backend.
- /// The endpoint name to use. Defaults to "http".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithLobbyBackend(
this IResourceBuilder builder,
@@ -1289,8 +1289,8 @@ public static IResourceBuilder WithLobbyRegistration(
/// The resource type (must support environment variables).
/// The resource builder.
/// The Aspire resource that exposes the lobby registration endpoint.
- /// The route on the lobby service that starts registration, e.g. "/register".
- /// The endpoint name to use. Defaults to "http".
+ /// The route on the lobby service that starts registration, e.g. "/register".
+ /// The endpoint name to use. Defaults to "http".
/// The same for chaining.
public static IResourceBuilder WithLobbyRegistration(
this IResourceBuilder builder,
diff --git a/Source/Aspire/IdentityVerificationMode.cs b/Source/Aspire/IdentityVerificationMode.cs
index c2bf3dd..f0af2ce 100644
--- a/Source/Aspire/IdentityVerificationMode.cs
+++ b/Source/Aspire/IdentityVerificationMode.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Aspire;
///
-/// Represents what a service's answer on /.cratis/me means to AuthProxy.
+/// Represents what a service's answer on /.cratis/me means to AuthProxy.
///
///
/// Mirrors the AuthProxy configuration enumeration of the same name. The member names are what is written
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Aspire;
public enum IdentityVerificationMode
{
///
- /// The endpoint enriches identity details. Any answer that is not an explicit 403 lets the
+ /// The endpoint enriches identity details. Any answer that is not an explicit 403 lets the
/// request through and merges whatever details came with it, including an unreachable service, a
/// timeout, another non-success status, an empty body, an unparseable body, and a body whose own
/// verdict is negative. This is the released behavior and the default.
diff --git a/Source/Aspire/InvitationCompletionDestination.cs b/Source/Aspire/InvitationCompletionDestination.cs
index 97667d3..324f875 100644
--- a/Source/Aspire/InvitationCompletionDestination.cs
+++ b/Source/Aspire/InvitationCompletionDestination.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Aspire;
/// 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
+/// 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
diff --git a/Source/Aspire/TrustedProxyEntry.cs b/Source/Aspire/TrustedProxyEntry.cs
index ee518e5..813aa8d 100644
--- a/Source/Aspire/TrustedProxyEntry.cs
+++ b/Source/Aspire/TrustedProxyEntry.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Aspire;
/// Decides whether a declared trusted proxy is something AuthProxy will be able to resolve.
///
///
-/// Deliberately a copy of the rule AuthProxy applies in TrustedProxyAddress rather than a call into
+/// Deliberately a copy of the rule AuthProxy applies in TrustedProxyAddress rather than a call into
/// it: this package is a hosting integration that an app host references on its own, without the proxy
/// assembly, the same reason and are
/// declared here too. Keep the two in step — the value of checking here is that the answer matches what the
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_caller_forges_the_identity_cookies.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_caller_forges_the_identity_cookies.cs
index 587576c..659668f 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_caller_forges_the_identity_cookies.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_caller_forges_the_identity_cookies.cs
@@ -6,15 +6,15 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
///
/// OWASP A01 / A08 — the decision that a caller is authorized must not be something the caller can write.
///
-/// AuthProxy asks every configured service's /.cratis/me endpoint whether a signed-in user is
+/// AuthProxy asks every configured service's /.cratis/me endpoint whether a signed-in user is
/// authorized at all, and remembers the answer so the question is not re-asked on every proxied request.
-/// Where that memory lives is the whole security question. The readable .cratis-identity cookie
+/// Where that memory lives is the whole security question. The readable .cratis-identity cookie
/// cannot be it: it is written non-HTTP-only on purpose, so a frontend can render the signed-in user from
/// it, which means script on any proxied origin can rewrite it and any non-browser client can simply send
-/// one. Treating its presence as proof meant Cookie: .cratis-identity=x alongside a valid session
+/// one. Treating its presence as proof meant Cookie: .cratis-identity=x alongside a valid session
/// skipped the authorization call entirely — a user whose access had been revoked stayed authorized for as
/// long as they chose to keep sending it, and no expiry could stop them, because a cookie's
-/// Max-Age is a request to the browser rather than a rule.
+/// Max-Age is a request to the browser rather than a rule.
///
///
/// So the authorization answer is remembered in a separate HTTP-only cookie sealed with data protection
@@ -71,7 +71,7 @@ public void should_still_resolve_identity_despite_both_cookies_being_forged() =>
/// to authorize them.
///
/// A label making the user recognizable in a failure.
- /// The raw Cookie header to present, if any.
+ /// The raw Cookie header to present, if any.
/// when the origin was asked; otherwise .
///
/// A fresh user each time, because the resolver also keeps a short-lived server-side cache keyed by
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_claim_gate_is_configured.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_claim_gate_is_configured.cs
index b4b327e..57cd291 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_claim_gate_is_configured.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_a_claim_gate_is_configured.cs
@@ -15,7 +15,7 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
///
/// Asserted against a real origin rather than on the client-facing status, because the status is the part
/// that is easy to get right. The refusal is only worth anything if nothing reached the backend: the
-/// request itself, obviously, but also the proxy's own /.cratis/me identity call, which runs later
+/// request itself, obviously, but also the proxy's own /.cratis/me identity call, which runs later
/// in the pipeline and would otherwise mean a refused caller still caused work — and a log entry — inside
/// the application.
///
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_capability_only_admission_is_configured.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_capability_only_admission_is_configured.cs
index 3945c3f..8482653 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_capability_only_admission_is_configured.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_capability_only_admission_is_configured.cs
@@ -16,8 +16,8 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
/// forward without a session.
///
///
-/// Asserted against a real origin rather than on the client-facing status. A 404 that still caused a
-/// forwarded request, or still caused the proxy's own /.cratis/me call, is a refusal that did work
+/// Asserted against a real origin rather than on the client-facing status. A 404 that still caused a
+/// forwarded request, or still caused the proxy's own /.cratis/me call, is a refusal that did work
/// and wrote a log line inside the application on behalf of a caller who was refused — and the declared
/// anonymous path is the pointed case, because that is the one route which normally reaches the backend
/// with no session at all.
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_headers_are_spoofed.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_headers_are_spoofed.cs
index 4d6c83d..3f9a56e 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_headers_are_spoofed.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_headers_are_spoofed.cs
@@ -6,8 +6,8 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
///
/// OWASP A01 — Broken Access Control. A caller must not be able to tell the origin who they are.
///
-/// AuthProxy's entire value is that a backend can trust x-ms-client-principal,
-/// x-ms-client-principal-id, x-ms-client-principal-name and Tenant-ID as proof of
+/// AuthProxy's entire value is that a backend can trust x-ms-client-principal,
+/// x-ms-client-principal-id, x-ms-client-principal-name and Tenant-ID as proof of
/// identity, because the proxy is the only thing that writes them. If an inbound copy survived to the
/// origin, every backend behind the proxy would be authenticating whoever asked — the single worst failure
/// this component can have, and one no client-facing response would reveal. So the assertion is made
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs
index 5344b1f..b05945d 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_identity_verification_is_required.cs
@@ -4,10 +4,10 @@
namespace Cratis.AuthProxy.Security.for_AccessControl;
///
-/// OWASP A01 / A05 — a deployment whose /.cratis/me endpoint answers with an authorization decision
+/// OWASP A01 / A05 — a deployment whose /.cratis/me endpoint answers with an authorization decision
/// must not admit callers when that decision could not be obtained.
///
-/// The released proxy caught every transport failure, timeout, non-403 status, empty body and parse
+/// The released proxy caught every transport failure, timeout, non-403 status, empty body and parse
/// failure and answered them with an empty-but-successful identity, then hard-coded the result to authorized
/// and sealed it into a cookie. So the one moment the proxy knew least about a caller — the backend it asks
/// being down, unreachable, or answering something it could not read — was the moment it was most
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_ascii.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_ascii.cs
index 1f37359..fceba6e 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_ascii.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_ascii.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
/// The counterpart to , and the reason the encoding is
/// applied conditionally rather than always: an existing deployment must see the identity headers it has
/// always seen, byte for byte, with nothing added. Backends parse these headers, and a proxy that started
-/// sending UTF-8''user%40example.com to everyone would break every one of them at once.
+/// sending UTF-8''user%40example.com to everyone would break every one of them at once.
///
/// Asserted against the header dictionary the origin actually recorded, so the claim is about what went
/// over the socket rather than about what the transform intended.
diff --git a/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_not_ascii.cs b/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_not_ascii.cs
index 8b1645a..fd7f876 100644
--- a/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_not_ascii.cs
+++ b/Source/AuthProxy.Security.Specs/for_AccessControl/when_the_caller_name_is_not_ascii.cs
@@ -10,13 +10,13 @@ namespace Cratis.AuthProxy.Security.for_AccessControl;
/// a caller no backend can authorize, which is a denial of access every bit as total as a wrong policy.
///
/// A display name is whatever the identity provider says it is, and providers say things like
-/// Søren Wærstad. .NET will not write a character above U+007F to a header field — it throws
+/// Søren Wærstad. .NET will not write a character above U+007F to a header field — it throws
/// before a byte reaches the socket — so such a person's proxied request failed at the gateway, their
-/// /.cratis/me resolution failed silently, and the application did not work for them at all.
+/// /.cratis/me resolution failed silently, and the application did not work for them at all.
///
///
/// Only a real socket can show this. An in-memory transform spec passes either way, because
-/// HttpRequestMessage.Headers.Add checks for CR, LF and NUL and nothing else — the ASCII refusal
+/// HttpRequestMessage.Headers.Add checks for CR, LF and NUL and nothing else — the ASCII refusal
/// happens in the connection, when the request is written. So the assertion is made against a real origin,
/// on stock Kestrel defaults, with no request-header encoding configured anywhere.
///
diff --git a/Source/AuthProxy.Security.Specs/for_ClosedDeployment/when_answering_on_a_real_listener.cs b/Source/AuthProxy.Security.Specs/for_ClosedDeployment/when_answering_on_a_real_listener.cs
index 0ba2635..4b60c8a 100644
--- a/Source/AuthProxy.Security.Specs/for_ClosedDeployment/when_answering_on_a_real_listener.cs
+++ b/Source/AuthProxy.Security.Specs/for_ClosedDeployment/when_answering_on_a_real_listener.cs
@@ -8,12 +8,12 @@ namespace Cratis.AuthProxy.Security.for_ClosedDeployment;
/// asserting that requires reading the wire.
///
///
-/// Every other spec about this mode runs on WebApplicationFactory's in-memory test server, and an
+/// Every other spec about this mode runs on WebApplicationFactory's in-memory test server, and an
/// exhaustive comparison taken there compares what the *application* wrote. Two things a caller receives are
/// invisible at that layer, and both of them were wrong:
///
/// -
-/// Kestrel adds its own Server header at serialization time, after every middleware has stopped
+/// Kestrel adds its own Server header at serialization time, after every middleware has stopped
/// touching the response — so clearing the headers cannot remove it, and it was only switched off as a side
/// effect of configuring a management port. A deployment whose stated purpose is not to be discoverable was
/// answering every scanner with the name of what is running it.
@@ -21,7 +21,7 @@ namespace Cratis.AuthProxy.Security.for_ClosedDeployment;
///
-
/// The management middleware runs ahead of the admission gate, so a management path offered to the *public*
/// listener was refused by the management endpoints instead — a different status framing, a different body
-/// and a Cache-Control nothing else carried. An unadmitted caller probing the documented default path
+/// and a Cache-Control nothing else carried. An unadmitted caller probing the documented default path
/// learned that an AuthProxy is here, that it has a management listener, and what its paths are called.
///
///
diff --git a/Source/AuthProxy.Security.Specs/for_Injection/when_a_caller_name_carries_crlf.cs b/Source/AuthProxy.Security.Specs/for_Injection/when_a_caller_name_carries_crlf.cs
index 58ef1aa..9053986 100644
--- a/Source/AuthProxy.Security.Specs/for_Injection/when_a_caller_name_carries_crlf.cs
+++ b/Source/AuthProxy.Security.Specs/for_Injection/when_a_caller_name_carries_crlf.cs
@@ -5,12 +5,12 @@ namespace Cratis.AuthProxy.Security.for_Injection;
///
/// OWASP A03 — Injection. A display name is attacker-influenced data that AuthProxy copies into a header
-/// field, which is the classic shape of response/request splitting: a name containing CRLF followed
-/// by x-ms-client-principal-id: attacker would, if it were written literally, hand the origin a
+/// field, which is the classic shape of response/request splitting: a name containing CRLF followed
+/// by x-ms-client-principal-id: attacker would, if it were written literally, hand the origin a
/// second identity header that the proxy never vouched for.
///
-/// The encoder cannot express that. Its output alphabet is the RFC 8187 attr-char set plus
-/// %, so CR, LF and NUL have no representation other than an escape — the property is structural
+/// The encoder cannot express that. Its output alphabet is the RFC 8187 attr-char set plus
+/// %, so CR, LF and NUL have no representation other than an escape — the property is structural
/// rather than a check that could be forgotten. Asserted against a real origin, because the only proof
/// that matters is the set of headers a backend actually received.
///
diff --git a/Source/AuthProxy.Security.Specs/for_Injection/when_a_path_traverses_out_of_the_pages_root.cs b/Source/AuthProxy.Security.Specs/for_Injection/when_a_path_traverses_out_of_the_pages_root.cs
index 5f18535..e20a16e 100644
--- a/Source/AuthProxy.Security.Specs/for_Injection/when_a_path_traverses_out_of_the_pages_root.cs
+++ b/Source/AuthProxy.Security.Specs/for_Injection/when_a_path_traverses_out_of_the_pages_root.cs
@@ -6,7 +6,7 @@ namespace Cratis.AuthProxy.Security.for_Injection;
///
/// OWASP A03 — Injection. A caller must not be able to name a file outside the pages directory.
///
-/// The /_pages branch is the one place AuthProxy reads the disk on behalf of an unauthenticated
+/// The /_pages branch is the one place AuthProxy reads the disk on behalf of an unauthenticated
/// caller, and by design it answers before authentication so a login or error page stays reachable without
/// a session. Anonymous, pre-auth and file-backed is the highest-value path-traversal combination the
/// component has: a deployment's configuration, its data-protection keys and its mounted secrets all live
@@ -14,24 +14,24 @@ namespace Cratis.AuthProxy.Security.for_Injection;
/// file read for anyone who can reach the host.
///
///
-/// That risk is concrete rather than theoretical here, because the proxy's own appsettings.json sits
-/// directly beside its Pages directory — one ../ is the entire distance between serving a
+/// That risk is concrete rather than theoretical here, because the proxy's own appsettings.json sits
+/// directly beside its Pages directory — one ../ is the entire distance between serving a
/// login page and serving the deployment's configuration. So the payloads walk the evasion ladder instead
-/// of repeating one canonical ../: percent-encoded dots, an encoded separator, doubled dots that
+/// of repeating one canonical ../: percent-encoded dots, an encoded separator, doubled dots that
/// survive a naive strip, double encoding, Windows-style backslashes, and a rooted path.
///
///
/// The payloads do not all arrive intact, and the spec is written so that this is visible rather than
/// papered over. Two layers below AuthProxy defuse most of them, and neither is this component's code:
/// collapses dot segments and rewrites backslashes as the request is built, so the three
-/// plain ../ forms are already ordinary paths before the proxy sees them and are then refused by the
-/// authentication gate; and the request path never decodes %2f into a separator, so the
+/// plain ../ forms are already ordinary paths before the proxy sees them and are then refused by the
+/// authentication gate; and the request path never decodes %2f into a separator, so the
/// encoded-separator forms arrive as one long literal file name inside the pages directory rather than as a
/// walk out of it. What is left reaches the pages handler and is refused there for want of a file.
///
///
/// That ordering is worth stating plainly, because it means the explicit containment check in
-/// ResolvePageAssetPath — resolving the full path and requiring it to stay under the directory — is
+/// ResolvePageAssetPath — resolving the full path and requiring it to stay under the directory — is
/// never the thing that says no in these runs. It is the backstop for the day a layer above stops
/// normalizing, which is exactly how traversal bugs have historically appeared. So the assertions are made
/// on the outcome (nothing from outside the page directories is ever returned, and no payload comes back
@@ -40,10 +40,10 @@ namespace Cratis.AuthProxy.Security.for_Injection;
///
///
/// A branch that answered 404 to everything would satisfy an outcome assertion while protecting nothing, so
-/// three requests prove the surface is live alongside the attacks: select-provider.html from the
-/// configured directory, 403.html from the content-root directory that is the actual sibling of
-/// appsettings.json, and the same file addressed with an encoded separator, which must fail — it is
-/// what establishes that %2f is inert here and that the encoded-separator payloads were therefore
+/// three requests prove the surface is live alongside the attacks: select-provider.html from the
+/// configured directory, 403.html from the content-root directory that is the actual sibling of
+/// appsettings.json, and the same file addressed with an encoded separator, which must fail — it is
+/// what establishes that %2f is inert here and that the encoded-separator payloads were therefore
/// never a traversal in the first place.
///
///
diff --git a/Source/AuthProxy.Security.Specs/for_Injection/when_request_values_carry_crlf.cs b/Source/AuthProxy.Security.Specs/for_Injection/when_request_values_carry_crlf.cs
index 03dcee3..30c35e0 100644
--- a/Source/AuthProxy.Security.Specs/for_Injection/when_request_values_carry_crlf.cs
+++ b/Source/AuthProxy.Security.Specs/for_Injection/when_request_values_carry_crlf.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Security.for_Injection;
///
/// CR/LF injection is header forgery through the back door. AuthProxy copies caller-supplied values into
/// two places where a line break would be catastrophic: the request it forwards to the origin, and the
-/// Location it hands the browser. A newline surviving into the forwarded request means the attacker
+/// Location it hands the browser. A newline surviving into the forwarded request means the attacker
/// is writing headers the backend trusts — the same identity forgery the proxy exists to prevent, only
/// smuggled inside a value nobody thought to sanitize. A newline surviving into a response splits the
/// response itself: the attacker appends headers, or a second body the browser accepts as a legitimate
@@ -19,27 +19,27 @@ namespace Cratis.AuthProxy.Security.for_Injection;
/// The payloads travel in URLs rather than in headers, because a header attempt would test the client
/// instead of the proxy — will not put a control character on the
/// wire. Both forms are sent, and both arrive: percent-escapes the raw
-/// CR/LF as it builds the request, so the raw and the pre-encoded payload reach AuthProxy as
+/// CR/LF as it builds the request, so the raw and the pre-encoded payload reach AuthProxy as
/// the same bytes — and ASP.NET Core hands both back as genuine control characters once the query value is
/// decoded. Nothing is normalized away here; every payload reaches the code under test intact.
///
///
/// Three sinks are probed. The query string of a proxied request lands in the request the backend sees, so
/// that assertion is made against what the origin actually recorded rather than against the client-facing
-/// response. The returnUrl of /.cratis/login/{scheme} is the value AuthProxy normalizes and
+/// response. The returnUrl of /.cratis/login/{scheme} is the value AuthProxy normalizes and
/// round-trips through the identity provider; the endpoint runs and consumes it, but this harness replaces
/// the authentication schemes to present a session as a header, which leaves the configured provider with
-/// no handler to challenge with, so that probe cannot produce a redirect to read. The redirect of
-/// /.cratis/logout is therefore probed as well — it is the sink in this harness that does reach a
-/// real Location header, and without it the response-splitting assertions would pass by never
+/// no handler to challenge with, so that probe cannot produce a redirect to read. The redirect of
+/// /.cratis/logout is therefore probed as well — it is the sink in this harness that does reach a
+/// real Location header, and without it the response-splitting assertions would pass by never
/// having a redirect to inspect.
///
///
/// That third probe is written to leave the redirect policy only one thing to object to. A payload that
-/// does not begin with / is refused for not looking same-site at all, which would prove nothing
+/// does not begin with / is refused for not looking same-site at all, which would prove nothing
/// about line breaks, so the redirect variants keep a real same-site path in front and drop the space out
-/// of the forged header. What remains that a policy could refuse is the CR and the LF. A
-/// clean target is sent through the same endpoint as a control, so the payloads falling back to /
+/// of the forged header. What remains that a policy could refuse is the CR and the LF. A
+/// clean target is sent through the same endpoint as a control, so the payloads falling back to /
/// reads as a refusal rather than as an endpoint that ignores the parameter.
///
///
@@ -171,7 +171,7 @@ [.. headers.Select(header => header.Key)],
/// The failure that replaced an answer, or an empty string when there was one.
/// Every response header name.
/// Every response header value, concatenated.
- /// The raw Location header, or an empty string when there was none.
+ /// The raw Location header, or an empty string when there was none.
sealed record Exchange(bool Reached, string Failure, IReadOnlyCollection HeaderNames, string HeaderValues, string Location)
{
///
@@ -181,11 +181,11 @@ sealed record Exchange(bool Reached, string Failure, IReadOnlyCollection
///
/// The harness replaces the authentication schemes so a spec can present a session as a header, and
/// that leaves the configured OIDC provider without a handler to challenge with. The endpoint still
- /// runs — it parses and normalizes the caller's returnUrl before it ever asks for a
+ /// runs — it parses and normalizes the caller's returnUrl before it ever asks for a
/// challenge, so the payload does reach the code under test — but dispatch then fails and there is
/// no response to read a header from. Recognizing that exact failure keeps it from silently
/// standing in for a genuine refusal, and makes this spec fail the day the scheme becomes
- /// challengeable and a real Location needs asserting on.
+ /// challengeable and a real Location needs asserting on.
///
public bool FailedOnlyAtSchemeDispatch =>
Failure.Contains("No authentication handler is registered", StringComparison.Ordinal);
diff --git a/Source/AuthProxy.Security.Specs/for_OpenRedirect/when_the_logout_endpoint_is_given_a_hostile_target.cs b/Source/AuthProxy.Security.Specs/for_OpenRedirect/when_the_logout_endpoint_is_given_a_hostile_target.cs
index 5d1124b..d099608 100644
--- a/Source/AuthProxy.Security.Specs/for_OpenRedirect/when_the_logout_endpoint_is_given_a_hostile_target.cs
+++ b/Source/AuthProxy.Security.Specs/for_OpenRedirect/when_the_logout_endpoint_is_given_a_hostile_target.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Security.for_OpenRedirect;
///
///
/// A single leading slash is not what makes a URL same-site, and the payloads below are the ways that
-/// assumption fails. //evil.test is protocol-relative. /\evil.test is the same URL to every
+/// assumption fails. //evil.test is protocol-relative. /\evil.test is the same URL to every
/// major browser, which normalize a backslash to a slash in the authority position. A slash followed by a
/// tab, carriage return or newline is also the same URL, because browsers strip those characters before
/// parsing — so the string the server checked and the URL the browser fetched are different strings. Every
diff --git a/Source/AuthProxy.Security.Specs/for_RequestSmuggling/when_hop_by_hop_headers_are_sent.cs b/Source/AuthProxy.Security.Specs/for_RequestSmuggling/when_hop_by_hop_headers_are_sent.cs
index 4decf37..d9ed3d7 100644
--- a/Source/AuthProxy.Security.Specs/for_RequestSmuggling/when_hop_by_hop_headers_are_sent.cs
+++ b/Source/AuthProxy.Security.Specs/for_RequestSmuggling/when_hop_by_hop_headers_are_sent.cs
@@ -7,13 +7,13 @@ namespace Cratis.AuthProxy.Security.for_RequestSmuggling;
/// A proxy must end the client's connection and start a new one, not extend the client's connection to the
/// origin. Hop-by-hop headers are the difference.
///
-/// Connection, Keep-Alive, Proxy-Connection, Upgrade, TE and
-/// Transfer-Encoding describe a single hop — how these two endpoints agreed to frame and hold this
+/// Connection, Keep-Alive, Proxy-Connection, Upgrade, TE and
+/// Transfer-Encoding describe a single hop — how these two endpoints agreed to frame and hold this
/// one connection. Relaying them makes the origin negotiate a connection with a client it is not connected
-/// to. That is where request smuggling lives: an attacker who can get Transfer-Encoding past the
+/// to. That is where request smuggling lives: an attacker who can get Transfer-Encoding past the
/// proxy makes the proxy and the origin disagree about where one request ends and the next begins, and
/// then owns the front of somebody else's request — every access-control decision the proxy just made gets
-/// applied to a body the attacker wrote. Upgrade is the same failure in one step: an origin that
+/// applied to a body the attacker wrote. Upgrade is the same failure in one step: an origin that
/// accepts a relayed upgrade leaves a raw tunnel behind the proxy that no later request ever passes through
/// it again.
///
@@ -63,7 +63,7 @@ public async Task InitializeAsync()
///
/// Records the one deviation: RFC 9110 section 7.6.1 also asks an intermediary to drop every field the
- /// Connection header names, and this one survives — the connection-token list is not
+ /// Connection header names, and this one survives — the connection-token list is not
/// parsed, only the standard hop-by-hop set is stripped.
///
///
@@ -71,7 +71,7 @@ public async Task InitializeAsync()
/// should be a deliberate one. It is not exploitable: the mechanism only ever removes headers,
/// so ignoring it forwards more than was asked rather than less, and the surviving header is one the
/// same attacker already chose to send. The direction that would matter — naming a trusted hop's
- /// identity headers in Connection to have them stripped — is closed by the Connection
+ /// identity headers in Connection to have them stripped — is closed by the Connection
/// header itself never being relayed.
///
[Fact]
diff --git a/Source/AuthProxy.Security.Specs/for_SecurityMisconfiguration/when_the_proxy_answers_an_error.cs b/Source/AuthProxy.Security.Specs/for_SecurityMisconfiguration/when_the_proxy_answers_an_error.cs
index 69e97ff..d3adede 100644
--- a/Source/AuthProxy.Security.Specs/for_SecurityMisconfiguration/when_the_proxy_answers_an_error.cs
+++ b/Source/AuthProxy.Security.Specs/for_SecurityMisconfiguration/when_the_proxy_answers_an_error.cs
@@ -18,7 +18,7 @@ namespace Cratis.AuthProxy.Security.for_SecurityMisconfiguration;
/// Endpoint selection runs ahead of authentication, so a caller who can make route matching itself fail
/// gets that failure — and in a misconfigured environment its stack trace — without presenting a
/// credential. That is what a request satisfying two routes at once used to do here: sending both the
-/// Service-ID header and the ?service= query parameter matched two candidate routes with the
+/// Service-ID header and the ?service= query parameter matched two candidate routes with the
/// same template and the same order, which ASP.NET reports as an ambiguity and surfaces as a bare 500 to
/// anyone at all. Both shapes are exercised below, authenticated and not, so a reordering that reopens the
/// ambiguity is caught rather than shipped.
diff --git a/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_client_credentials_verifier_answers_with_a_redirect.cs b/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_client_credentials_verifier_answers_with_a_redirect.cs
index 0e3a0e5..526c355 100644
--- a/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_client_credentials_verifier_answers_with_a_redirect.cs
+++ b/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_client_credentials_verifier_answers_with_a_redirect.cs
@@ -21,14 +21,14 @@ namespace Cratis.AuthProxy.Security.for_ServerSideRequestForgery;
///
/// This one is the sharpest of the set: the body is the client identifier and the client secret, in
/// plaintext, and the call is reachable from an entirely anonymous POST to the token endpoint. A handler
-/// that follows a 307 re-sends method and body to the named host — and while
-/// SocketsHttpHandler strips Authorization across origins, it strips neither the body nor a
+/// that follows a 307 re-sends method and body to the named host — and while
+/// SocketsHttpHandler strips Authorization across origins, it strips neither the body nor a
/// custom header, so nothing about the credentials is left behind.
///
/// Asserted against real sockets and the real registration, for the same reason the capability verifier's
/// twin is: the redirect is followed, if it is followed at all, inside the primary message handler, below
/// every seam a substitute could stand in at. The client under test is registered by
-/// AddIngressAuthentication with no handler configuration of its own, so what this pins is that the
+/// AddIngressAuthentication with no handler configuration of its own, so what this pins is that the
/// pipeline's default covers a client that never asked to be covered.
///
///
diff --git a/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_verifier_answers_with_a_redirect.cs b/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_verifier_answers_with_a_redirect.cs
index c08863d..42cada6 100644
--- a/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_verifier_answers_with_a_redirect.cs
+++ b/Source/AuthProxy.Security.Specs/for_ServerSideRequestForgery/when_the_verifier_answers_with_a_redirect.cs
@@ -22,7 +22,7 @@ namespace Cratis.AuthProxy.Security.for_ServerSideRequestForgery;
///
/// The startup validator constrains the verifier to one absolute http or https URL precisely so a deployment
/// cannot be pointed somewhere it did not mean. A message handler that follows redirects hands that
-/// constraint back on the first 3xx: an unauthenticated POST to the presentation path becomes an
+/// constraint back on the first 3xx: an unauthenticated POST to the presentation path becomes an
/// AuthProxy-originated POST to any host the proxy can route to, carrying the caller's plaintext capability
/// in the body — an internal metadata service, an admin API, anything reachable from inside.
///
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_host_or_prefix_is_forwarded.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_host_or_prefix_is_forwarded.cs
index 019f033..f24cb68 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_host_or_prefix_is_forwarded.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_host_or_prefix_is_forwarded.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Security.for_TrustedProxies;
///
/// The host is the second half of the proxy's own public origin — the half a spoofed scheme cannot reach on
/// its own — so a deployment that started honoring it would let a caller name the origin the OIDC
-/// post_logout_redirect_uri points at and the origin the post-logout allow-list admits. The prefix
+/// post_logout_redirect_uri points at and the origin the post-logout allow-list admits. The prefix
/// moves every path the proxy matches on, which would silently reclassify a protected path as an anonymous
/// one. Neither is a header anyone would notice becoming trusted, which is why it is asserted rather than
/// assumed.
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_a_cookie.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_a_cookie.cs
index bfe56ce..1534dd5 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_a_cookie.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_a_cookie.cs
@@ -4,13 +4,13 @@
namespace Cratis.AuthProxy.Security.for_TrustedProxies;
///
-/// OWASP A05 — Security Misconfiguration. A spoofed scheme must not reach the Secure flag on a cookie.
+/// OWASP A05 — Security Misconfiguration. A spoofed scheme must not reach the Secure flag on a cookie.
///
-/// Eleven places in AuthProxy set a cookie's Secure flag from Request.IsHttps, which forwarded
+/// Eleven places in AuthProxy set a cookie's Secure flag from Request.IsHttps, which forwarded
/// headers decide. The direction that matters is the one asserted here, and it is the quiet one: a caller
-/// that can spoof the scheme upward makes an unencrypted deployment mark its cookies Secure,
+/// that can spoof the scheme upward makes an unencrypted deployment mark its cookies Secure,
/// and a browser then silently withholds them — a session that stops working for reasons no log explains. The
-/// mirror image is worse and is the same defect: honoring a downward spoof would strip Secure from a
+/// mirror image is worse and is the same defect: honoring a downward spoof would strip Secure from a
/// session that genuinely is encrypted, leaving it working perfectly and unprotected.
///
///
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_not_trusted.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_not_trusted.cs
index d45970f..2418c81 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_not_trusted.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_not_trusted.cs
@@ -7,8 +7,8 @@ namespace Cratis.AuthProxy.Security.for_TrustedProxies.when_a_spoofed_scheme_rea
/// OWASP A01 — Broken Access Control (open redirect). A caller must not be able to widen the post-logout
/// allow-list by naming the scheme the proxy thinks it is served over.
///
-/// The allow-list admits the proxy's own public origin, built from Request.Scheme and
-/// Request.Host. A caller that could set the scheme would add a second origin to that list — the same
+/// The allow-list admits the proxy's own public origin, built from Request.Scheme and
+/// Request.Host. A caller that could set the scheme would add a second origin to that list — the same
/// host under the other scheme — and the logout endpoint would then redirect to it. The target chosen here is
/// exactly that: the same host, the other scheme, and nothing else about the deployment changed.
///
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_trusted.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_trusted.cs
index 263960d..1309161 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_trusted.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_a_spoofed_scheme_reaches_the_logout_origin/and_the_peer_is_trusted.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Security.for_TrustedProxies.when_a_spoofed_scheme_rea
/// The same request from the deployment's own ingress is honored, which is what proves the refusal next to it
/// is a decision about the caller rather than a redirect that never worked.
///
-/// A deployment terminating TLS at its ingress genuinely is served over https, and its logout has to
+/// A deployment terminating TLS at its ingress genuinely is served over https, and its logout has to
/// be able to return there. This is the case that would break if the boundary were drawn by refusing every
/// forwarded scheme rather than by asking who sent it.
///
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_an_rfc_7239_forwarded_header_is_sent.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_an_rfc_7239_forwarded_header_is_sent.cs
index 94637c7..3d363c4 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_an_rfc_7239_forwarded_header_is_sent.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_an_rfc_7239_forwarded_header_is_sent.cs
@@ -4,9 +4,9 @@
namespace Cratis.AuthProxy.Security.for_TrustedProxies;
///
-/// The standardized Forwarded header changes nothing, even from a peer the deployment trusts.
+/// The standardized Forwarded header changes nothing, even from a peer the deployment trusts.
///
-/// It is the header a reader of RFC 7239 would reach for, and AuthProxy consumes only the X-Forwarded-*
+/// It is the header a reader of RFC 7239 would reach for, and AuthProxy consumes only the X-Forwarded-*
/// family — so a value here is inert. That is worth pinning from the trusted side rather than the untrusted
/// one: from an outsider it would be refused anyway, and the claim being made is the stronger one that no
/// amount of trust makes this header mean anything. The sign-in notification is asserted alongside, because a
diff --git a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_forwarded_headers_come_from_an_untrusted_peer.cs b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_forwarded_headers_come_from_an_untrusted_peer.cs
index 5cb50da..3a274da 100644
--- a/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_forwarded_headers_come_from_an_untrusted_peer.cs
+++ b/Source/AuthProxy.Security.Specs/for_TrustedProxies/when_forwarded_headers_come_from_an_untrusted_peer.cs
@@ -7,10 +7,10 @@ namespace Cratis.AuthProxy.Security.for_TrustedProxies;
/// OWASP A05 — Security Misconfiguration. A caller that is not one of the deployment's own proxies must not
/// be able to say where it is or how it got here.
///
-/// X-Forwarded-For and X-Forwarded-Proto are ordinary request headers, and until a boundary is
+/// X-Forwarded-For and X-Forwarded-Proto are ordinary request headers, and until a boundary is
/// declared they are believed from anyone who can open a connection. The address is what gets written into
/// the audit record of a sign-in; the scheme is what decides whether eleven session cookies carry
-/// Secure, what the OIDC post_logout_redirect_uri claims the proxy's public origin to be, and
+/// Secure, what the OIDC post_logout_redirect_uri claims the proxy's public origin to be, and
/// which origins the post-logout allow-list admits. All of that is settled by two values, so the two values
/// are what this asserts on.
///
diff --git a/Source/AuthProxy.Security.Specs/given/CapabilityOnlyHarness.cs b/Source/AuthProxy.Security.Specs/given/CapabilityOnlyHarness.cs
index d9dbc28..7215e47 100644
--- a/Source/AuthProxy.Security.Specs/given/CapabilityOnlyHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/CapabilityOnlyHarness.cs
@@ -23,7 +23,7 @@ namespace Cratis.AuthProxy.Security.given;
/// It is deliberately end to end and deliberately configured with everything a caller could learn something
/// from — a declared anonymous path, configured providers, real page assets and a real origin. The question
/// these specs ask is not what the client sees, which is easy to get right, but whether anything at all
-/// reached the application: a refusal that still caused a request, a /.cratis/me call or a log entry
+/// reached the application: a refusal that still caused a request, a /.cratis/me call or a log entry
/// inside the backend is a refusal that told an unadmitted caller the backend is there.
///
///
diff --git a/Source/AuthProxy.Security.Specs/given/ClaimGatedHarness.cs b/Source/AuthProxy.Security.Specs/given/ClaimGatedHarness.cs
index 3516fdc..dea4ecd 100644
--- a/Source/AuthProxy.Security.Specs/given/ClaimGatedHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/ClaimGatedHarness.cs
@@ -88,7 +88,7 @@ public static HttpRequestMessage Unqualified(string pathAndQuery) =>
/// Builds a request from an authenticated caller carrying the given claims.
///
/// The path and query to request.
- /// The claims, as type=value pairs separated by semicolons.
+ /// The claims, as type=value pairs separated by semicolons.
/// The request.
public static HttpRequestMessage WithClaims(string pathAndQuery, string claims)
{
diff --git a/Source/AuthProxy.Security.Specs/given/ClosedDeploymentHarness.cs b/Source/AuthProxy.Security.Specs/given/ClosedDeploymentHarness.cs
index 4de94be..93d920c 100644
--- a/Source/AuthProxy.Security.Specs/given/ClosedDeploymentHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/ClosedDeploymentHarness.cs
@@ -28,9 +28,9 @@ namespace Cratis.AuthProxy.Security.given;
/// one that did not — so a refusal can be read as the bytes that actually leave the process.
///
///
-/// Real sockets, and for a reason the rest of this suite's harnesses cannot cover. TestServer is not a
-/// server: it writes no Server header, honors no AddServerHeader, reports
-/// Connection.LocalPort as zero and never serializes a response. Everything a spec observes there is
+/// Real sockets, and for a reason the rest of this suite's harnesses cannot cover. TestServer is not a
+/// server: it writes no Server header, honors no AddServerHeader, reports
+/// Connection.LocalPort as zero and never serializes a response. Everything a spec observes there is
/// what the *application* wrote, so an exhaustive header comparison taken at that layer proves the
/// application is uniform and says nothing about what a caller receives — which is where both of the
/// distinguishable refusals this harness exists for were hiding.
@@ -100,12 +100,12 @@ public ClosedDeploymentHarness()
///
/// The port to ask.
/// The path to ask for.
- /// The response, verbatim, with the Date line removed.
+ /// The response, verbatim, with the Date line removed.
///
/// Raw rather than through because the question is what the process writes, and
- /// a client hands back a parsed view of it — status line casing, header order, a Server header, a
- /// chunked body framing. Connection: close so the response ends at end of stream and no framing
- /// has to be re-implemented here. Only Date is removed, because it is a clock reading rather than
+ /// a client hands back a parsed view of it — status line casing, header order, a Server header, a
+ /// chunked body framing. Connection: close so the response ends at end of stream and no framing
+ /// has to be re-implemented here. Only Date is removed, because it is a clock reading rather than
/// anything about the request.
///
public static async Task Raw(int port, string path)
diff --git a/Source/AuthProxy.Security.Specs/given/ForwardedRequest.cs b/Source/AuthProxy.Security.Specs/given/ForwardedRequest.cs
index ee0d3f0..ba8da59 100644
--- a/Source/AuthProxy.Security.Specs/given/ForwardedRequest.cs
+++ b/Source/AuthProxy.Security.Specs/given/ForwardedRequest.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Security.given;
///
/// The HTTP method.
/// The request path.
-/// The query string, including the leading ? when present.
+/// The query string, including the leading ? when present.
/// Every header the origin received, keyed case-insensitively.
public sealed record ForwardedRequest(
string Method,
diff --git a/Source/AuthProxy.Security.Specs/given/HeaderAuthenticationHandler.cs b/Source/AuthProxy.Security.Specs/given/HeaderAuthenticationHandler.cs
index 91dfe22..4a64477 100644
--- a/Source/AuthProxy.Security.Specs/given/HeaderAuthenticationHandler.cs
+++ b/Source/AuthProxy.Security.Specs/given/HeaderAuthenticationHandler.cs
@@ -59,7 +59,7 @@ public class HeaderAuthenticationHandler(
///
/// Encodes claim declarations so a spec can put them on .
///
- /// The type=value pairs, separated by semicolons.
+ /// The type=value pairs, separated by semicolons.
/// The declarations in the form the header carries.
public static string EncodeClaims(string declarations) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes(declarations));
diff --git a/Source/AuthProxy.Security.Specs/given/ManagementListenerHarness.cs b/Source/AuthProxy.Security.Specs/given/ManagementListenerHarness.cs
index ccbbd3c..5d1b6a4 100644
--- a/Source/AuthProxy.Security.Specs/given/ManagementListenerHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/ManagementListenerHarness.cs
@@ -28,8 +28,8 @@ namespace Cratis.AuthProxy.Security.given;
///
///
/// Real sockets, deliberately, and the only place in this suite that needs them. Every other security spec
-/// runs on WebApplicationFactory's in-memory test server, which has no socket at all and reports
-/// Connection.LocalPort as zero — so every assertion about which listener a request arrived on would
+/// runs on WebApplicationFactory's in-memory test server, which has no socket at all and reports
+/// Connection.LocalPort as zero — so every assertion about which listener a request arrived on would
/// pass without proving anything, including against an implementation that isolated nothing.
///
/// The bare deployment is here because "no management section changes nothing" is a claim about what is
@@ -37,7 +37,7 @@ namespace Cratis.AuthProxy.Security.given;
/// exactly like the other one minus the section.
///
///
-/// The pipeline mirrors Program.cs rather than running it, because these specs have to choose the
+/// The pipeline mirrors Program.cs rather than running it, because these specs have to choose the
/// addresses and the origin before the host is built. The registration and the pipeline placement it
/// mirrors are the two things under test, so both are called here exactly as the program calls them.
///
diff --git a/Source/AuthProxy.Security.Specs/given/ObservedRequest.cs b/Source/AuthProxy.Security.Specs/given/ObservedRequest.cs
index 2f20566..71066ac 100644
--- a/Source/AuthProxy.Security.Specs/given/ObservedRequest.cs
+++ b/Source/AuthProxy.Security.Specs/given/ObservedRequest.cs
@@ -7,14 +7,14 @@ namespace Cratis.AuthProxy.Security.given;
/// Represents the state a request was normalized to by the time the proxy was done with it.
///
/// The address every later decision treats as the client's.
-/// The scheme every later decision treats as the browser's — which is what settles cookie Secure and the proxy's own public origin.
+/// The scheme every later decision treats as the browser's — which is what settles cookie Secure and the proxy's own public origin.
/// The host the request is treated as having arrived at.
/// The path prefix the request is treated as being mounted under.
-/// What was left of X-Forwarded-For after the middleware consumed from it.
+/// What was left of X-Forwarded-For after the middleware consumed from it.
///
/// These four values are the whole of what forwarded headers can change, and every downstream consequence in
-/// the proxy — the address recorded against a sign-in, the Secure flag on eleven cookies, the OIDC
-/// post_logout_redirect_uri, the post-logout origin allow-list — is derived from them. Asserting on
+/// the proxy — the address recorded against a sign-in, the Secure flag on eleven cookies, the OIDC
+/// post_logout_redirect_uri, the post-logout origin allow-list — is derived from them. Asserting on
/// them directly is what makes a spec about the boundary rather than about one of its symptoms.
///
public sealed record ObservedRequest(
diff --git a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
index 42386eb..ff24e33 100644
--- a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
+++ b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs
@@ -17,13 +17,13 @@ namespace Cratis.AuthProxy.Security.given;
///
///
/// The security question that matters most for a reverse proxy is not what it answers the client, but what
-/// it hands the origin — a spoofed x-ms-client-principal that reaches a backend is a full identity
+/// it hands the origin — a spoofed x-ms-client-principal that reaches a backend is a full identity
/// forgery, and no amount of inspecting the client-facing response would reveal it. Asserting on that
/// requires an origin that actually exists, so this listens on a real socket: YARP forwards over the
/// network stack rather than through the in-memory test server, and what arrives here is exactly what a
/// deployed backend would see.
///
-/// It also answers /.cratis/me, because AuthProxy calls it on every authenticated request to
+/// It also answers /.cratis/me, because AuthProxy calls it on every authenticated request to
/// resolve identity details, and a backend that refused would make every authenticated spec a 403 about
/// something else.
///
@@ -59,7 +59,7 @@ public sealed class RecordingBackend : IAsyncDisposable
/// Gets or sets what the origin answers on the identity endpoint.
///
///
- /// Settable because a deployment that treats /.cratis/me as an authorization decision has to be
+ /// Settable because a deployment that treats /.cratis/me as an authorization decision has to be
/// shown failing and then recovering, and the whole point of asserting end to end is that the failure
/// arrives the way a real one would — over a socket, from an origin that genuinely answered that way.
/// The default answers an empty object, which is what every other security spec's deployment expects.
@@ -147,7 +147,7 @@ public static async Task Start()
/// The path to look for.
/// The last request to that path, or when there was none.
///
- /// An authenticated request produces two calls here — the proxy's own /.cratis/me identity
+ /// An authenticated request produces two calls here — the proxy's own /.cratis/me identity
/// resolution and then the forwarded request itself — so a spec that means "the request I sent" has to
/// say which one, rather than trusting the order they happen to arrive in.
///
diff --git a/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs b/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs
index a342b29..1dde836 100644
--- a/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/RequiredVerificationHarness.cs
@@ -14,7 +14,7 @@
namespace Cratis.AuthProxy.Security.given;
///
-/// A running AuthProxy whose one service answers /.cratis/me with an authorization verdict, in front
+/// A running AuthProxy whose one service answers /.cratis/me with an authorization verdict, in front
/// of a real recording origin whose answer a spec controls.
///
///
diff --git a/Source/AuthProxy.Security.Specs/given/SimulatedPeerStartupFilter.cs b/Source/AuthProxy.Security.Specs/given/SimulatedPeerStartupFilter.cs
index 4ed995e..60bff1b 100644
--- a/Source/AuthProxy.Security.Specs/given/SimulatedPeerStartupFilter.cs
+++ b/Source/AuthProxy.Security.Specs/given/SimulatedPeerStartupFilter.cs
@@ -14,7 +14,7 @@ namespace Cratis.AuthProxy.Security.given;
///
///
///
-/// The in-memory test server never opens a socket, so Connection.RemoteIpAddress is
+/// The in-memory test server never opens a socket, so Connection.RemoteIpAddress is
/// on every request it serves. That is fatal to a spec about a trusted-proxy boundary and fatal in the worst
/// possible way: a null address matches no known network, so the untrusted direction passes for entirely the
/// wrong reason and the trusted direction cannot be written at all. A whole suite would sit there green while
diff --git a/Source/AuthProxy.Security.Specs/given/TrustedProxyHarness.cs b/Source/AuthProxy.Security.Specs/given/TrustedProxyHarness.cs
index 9f22fbc..08aa3f0 100644
--- a/Source/AuthProxy.Security.Specs/given/TrustedProxyHarness.cs
+++ b/Source/AuthProxy.Security.Specs/given/TrustedProxyHarness.cs
@@ -27,8 +27,8 @@ namespace Cratis.AuthProxy.Security.given;
///
///
/// Two identity providers are configured so an unauthenticated browser is served the selection page rather
-/// than challenged. That page sets a cookie whose Secure flag is taken straight from the request
-/// scheme, which is what makes a spoofed X-Forwarded-Proto observable as something a browser would act
+/// than challenged. That page sets a cookie whose Secure flag is taken straight from the request
+/// scheme, which is what makes a spoofed X-Forwarded-Proto observable as something a browser would act
/// on rather than as an internal value.
///
///
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_a_mode_that_is_not_public_names_no_verifier.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_a_mode_that_is_not_public_names_no_verifier.cs
index b5b93f7..46b938f 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_a_mode_that_is_not_public_names_no_verifier.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_a_mode_that_is_not_public_names_no_verifier.cs
@@ -10,7 +10,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionConfigurationValidator;
///
/// turns the gate on for every mode that is not
/// public, so any mode the validator skips is a deployment that starts clean and then refuses every caller
-/// alive with a 404 that says nothing. Two of the three places that branch on the mode were corrected
+/// alive with a 404 that says nothing. Two of the three places that branch on the mode were corrected
/// to ask "is this public"; this is what stops the third from drifting back, and what makes adding a third
/// mode fail here rather than in production.
///
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_capability_only_names_no_verifier.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_capability_only_names_no_verifier.cs
index 447f819..c9a9546 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_capability_only_names_no_verifier.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_capability_only_names_no_verifier.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionConfigurationValidator;
///
/// Closing the door without saying who holds the key is refused at startup rather than at every request.
-/// The alternative is a deployment that starts cleanly and then answers 404 to everyone alive, with
+/// The alternative is a deployment that starts cleanly and then answers 404 to everyone alive, with
/// nothing in the answer — by design — to say why.
///
public class when_capability_only_names_no_verifier : given.an_admission_configuration_validator
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_the_mode_is_not_recognized.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_the_mode_is_not_recognized.cs
index 1e726f2..0f94121 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_the_mode_is_not_recognized.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionConfigurationValidator/when_the_mode_is_not_recognized.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionConfigurationValidator;
///
/// The mode is asked about before anything else, because everything below is asked of a mode that was
-/// understood. A value outside the enum is neither Public nor CapabilityOnly, so the early
+/// understood. A value outside the enum is neither Public nor CapabilityOnly, so the early
/// return for "not capability-only" would hand it straight past every check here — leaving a deployment that
/// asked to be closed accepted as configured, and gated by nothing.
///
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionMiddleware/given/an_admission_middleware.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionMiddleware/given/an_admission_middleware.cs
index d2cb0fd..8feea32 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionMiddleware/given/an_admission_middleware.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionMiddleware/given/an_admission_middleware.cs
@@ -55,7 +55,7 @@ void Establish()
/// Builds the middleware over the current configuration.
///
///
- /// Deferred to the spec rather than done in Establish, so a spec can change the configuration
+ /// Deferred to the spec rather than done in Establish, so a spec can change the configuration
/// first — the options monitor captures the instance it is told about.
///
protected void BuildMiddleware()
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/given/an_admission_policy.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/given/an_admission_policy.cs
index 3b64787..be221da 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/given/an_admission_policy.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/given/an_admission_policy.cs
@@ -59,7 +59,7 @@ protected string SealedTransaction(TimeSpan expiresIn) =>
///
/// Puts the given cookies on the request.
///
- /// The cookies, as name=value pairs.
+ /// The cookies, as name=value pairs.
protected void Presenting(params string[] cookies) =>
_context.Request.Headers.Cookie = string.Join("; ", cookies);
}
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_nothing_is_presented.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_nothing_is_presented.cs
index 4e4ccb1..70f028c 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_nothing_is_presented.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_nothing_is_presented.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionPolicy.when_admitting_a_reques
/// A request carrying no entry transaction is not admitted.
///
/// This is the one spec the whole feature hangs on. The neighboring invitation binding
-/// (InvitationAuthenticationState.TryBindPendingInvitation) deliberately answers the opposite — it
+/// (InvitationAuthenticationState.TryBindPendingInvitation) deliberately answers the opposite — it
/// returns when its cookie is absent, because it validates a transaction that
/// exists rather than requiring one. Borrowing that shape here would leave a gate that admits everybody
/// while every happy-path spec above still passes and every refusal spec still refuses, because they all
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_the_entry_transaction_has_expired.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_the_entry_transaction_has_expired.cs
index e32496e..7aec400 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_the_entry_transaction_has_expired.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_admitting_a_request/and_the_entry_transaction_has_expired.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionPolicy.when_admitting_a_reques
///
/// An entry expires on AuthProxy's own clock rather than on the browser's willingness to stop sending the
-/// cookie. A cookie whose Max-Age has passed is simply one a browser has been asked not to send —
+/// cookie. A cookie whose Max-Age has passed is simply one a browser has been asked not to send —
/// nothing stops one being sent anyway.
///
public class and_the_entry_transaction_has_expired : given.an_admission_policy
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_the_mode_is_public.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_the_mode_is_public.cs
index 37921f9..abda1a1 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_the_mode_is_public.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionPolicy/when_the_mode_is_public.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionPolicy;
///
/// Naming the default explicitly is still the default. A deployment that writes
-/// Mode: Public — or that carries a capability section it has not switched on — is gated by nothing.
+/// Mode: Public — or that carries a capability section it has not switched on — is gated by nothing.
///
public class when_the_mode_is_public : given.an_admission_policy
{
diff --git a/Source/AuthProxy.Specs/Admission/for_AdmissionServiceCollectionExtensions/when_adding_admission.cs b/Source/AuthProxy.Specs/Admission/for_AdmissionServiceCollectionExtensions/when_adding_admission.cs
index fa2d33d..34a456b 100644
--- a/Source/AuthProxy.Specs/Admission/for_AdmissionServiceCollectionExtensions/when_adding_admission.cs
+++ b/Source/AuthProxy.Specs/Admission/for_AdmissionServiceCollectionExtensions/when_adding_admission.cs
@@ -11,15 +11,15 @@ namespace Cratis.AuthProxy.Admission.for_AdmissionServiceCollectionExtensions;
/// registration rather than from the constant it is configured with.
///
///
-/// CreateClient answers for a name nothing ever registered — with 's own
+/// CreateClient answers for a name nothing ever registered — with 's own
/// hundred-second default. So deleting the registration outright leaves every existing spec green and every
/// caller who has been admitted to nothing able to hold a request open for a hundred seconds against a
/// verifier that stopped answering. The timeout is the thing that stops that, and this is what says it is
/// still there.
///
-/// Resolved through AddIngressConfiguration rather than through AddAdmission alone, because
+/// Resolved through AddIngressConfiguration rather than through AddAdmission alone, because
/// that is what the process does and it is what makes the hazard reproducible: the ingress registration adds
-/// an unnamed HttpClient too, so a provider missing the named registration still hands back a client
+/// an unnamed HttpClient too, so a provider missing the named registration still hands back a client
/// — the silently wrong one — instead of failing to resolve anything.
///
///
diff --git a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/given/a_capability_admission.cs b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/given/a_capability_admission.cs
index aa0eb20..37f95f5 100644
--- a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/given/a_capability_admission.cs
+++ b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/given/a_capability_admission.cs
@@ -87,7 +87,7 @@ protected string IssuedCookieValue()
}
///
- /// Gets the whole Set-Cookie header the response carries for the entry transaction.
+ /// Gets the whole Set-Cookie header the response carries for the entry transaction.
///
/// The header value, or an empty string when none was issued.
protected string IssuedCookieHeader() =>
diff --git a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_capability_admits.cs b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_capability_admits.cs
index ec3b68d..39da44d 100644
--- a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_capability_admits.cs
+++ b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_capability_admits.cs
@@ -51,7 +51,7 @@ void should_seal_a_transaction_that_expires_with_the_entry_lifetime()
}
///
- /// A browser silently drops a Set-Cookie past 4096 bytes, and in this mode a dropped entry cookie
+ /// A browser silently drops a Set-Cookie past 4096 bytes, and in this mode a dropped entry cookie
/// means a caller who was admitted receives the uniform refusal for the rest of the entry's life — with
/// nothing in any response and nothing in any log to say why, because that is the whole design.
///
diff --git a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_presentation_arrives_over_https.cs b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_presentation_arrives_over_https.cs
index 3b88baf..d21f327 100644
--- a/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_presentation_arrives_over_https.cs
+++ b/Source/AuthProxy.Specs/Admission/for_CapabilityAdmission/when_the_presentation_arrives_over_https.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Admission.for_CapabilityAdmission;
///
-/// The entry transaction is marked Secure whenever the request itself is encrypted — which behind a
+/// The entry transaction is marked Secure whenever the request itself is encrypted — which behind a
/// TLS-terminating ingress is what the forwarded headers have already made it say.
///
public class when_the_presentation_arrives_over_https : given.a_capability_admission
diff --git a/Source/AuthProxy.Specs/Admission/for_EntryTransactionProtector/when_a_foreign_record_was_sealed_under_this_purpose.cs b/Source/AuthProxy.Specs/Admission/for_EntryTransactionProtector/when_a_foreign_record_was_sealed_under_this_purpose.cs
index fb60c33..33d750a 100644
--- a/Source/AuthProxy.Specs/Admission/for_EntryTransactionProtector/when_a_foreign_record_was_sealed_under_this_purpose.cs
+++ b/Source/AuthProxy.Specs/Admission/for_EntryTransactionProtector/when_a_foreign_record_was_sealed_under_this_purpose.cs
@@ -14,7 +14,7 @@ namespace Cratis.AuthProxy.Admission.for_EntryTransactionProtector;
///
/// The invitation entry state is the neighbor that matters: it is protected with the same key ring, it
/// carries its own transaction and challenge under different property names, and it carries an
-/// ExpiresAt that is live for exactly the same reason this one's is. Deserialized as an entry
+/// ExpiresAt that is live for exactly the same reason this one's is. Deserialized as an entry
/// transaction it yields a record whose transaction and challenge are null and whose expiry is in the
/// future — and the admission check looks at the expiry and nothing else, so it admits.
///
diff --git a/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs b/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs
index 556dd4c..eb9e943 100644
--- a/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs
+++ b/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Attestations.for_AttestationSigningContract;
///
/// The contract is a record, and a record's generated rendering prints every one of its properties — including
-/// the signing key. One LogDebug("{Contract}", contract) added by anyone, at any point, would write the
+/// the signing key. One LogDebug("{Contract}", contract) added by anyone, at any point, would write the
/// private key to the log without a single line of code looking wrong. The rendering has to be safe by
/// construction rather than by everybody remembering.
///
diff --git a/Source/AuthProxy.Specs/Authentication/GitHubApi.cs b/Source/AuthProxy.Specs/Authentication/GitHubApi.cs
index 62856f0..a45e120 100644
--- a/Source/AuthProxy.Specs/Authentication/GitHubApi.cs
+++ b/Source/AuthProxy.Specs/Authentication/GitHubApi.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Authentication;
///
///
/// Responses are produced per request rather than handed over up front, because a paged read disposes each
-/// response as it goes and asks for the next page from the previous one's Link header — a fixed
+/// response as it goes and asks for the next page from the previous one's Link header — a fixed
/// response would be read once and disposed before the second page needed it.
///
/// Produces the response for a requested URL.
diff --git a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_deciding_whether_a_provider_needs_enriching.cs b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_deciding_whether_a_provider_needs_enriching.cs
index 2eb05f8..1d64f01 100644
--- a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_deciding_whether_a_provider_needs_enriching.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_deciding_whether_a_provider_needs_enriching.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Authentication.for_GitHubMembershipClaimsEnricher;
/// Tying it to the scope rather than to a separate switch avoids the state where the two disagree — a
/// switch turned on without the scope produces empty membership and a deployment nobody can sign in to,
/// while the scope granted without the switch produces claims that were fetched and then not used. GitHub
-/// answers /user/orgs with public memberships only and refuses /user/teams outright without
+/// answers /user/orgs with public memberships only and refuses /user/teams outright without
/// it, so the scope is not merely correlated with wanting the claims: it is the condition under which they
/// can be true.
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_github_refuses_the_membership_read.cs b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_github_refuses_the_membership_read.cs
index a2c8307..a92c1e8 100644
--- a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_github_refuses_the_membership_read.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_github_refuses_the_membership_read.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Authentication.for_GitHubMembershipClaimsEnricher;
/// refuses with an explanation — the fail-closed direction reached the gentle way.
///
///
-/// GitHub genuinely answers this way: /user/teams is 403 for a token without the scope, and
+/// GitHub genuinely answers this way: /user/teams is 403 for a token without the scope, and
/// a network fault mid-handshake looks the same.
///
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_membership_spans_several_pages.cs b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_membership_spans_several_pages.cs
index bc3d0dd..dced7c7 100644
--- a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_membership_spans_several_pages.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_membership_spans_several_pages.cs
@@ -6,7 +6,7 @@ namespace Cratis.AuthProxy.Authentication.for_GitHubMembershipClaimsEnricher;
///
/// Paging is followed, and only within the host the read started from.
///
-/// GitHub pages every collection and links the next page from a Link header rather than from the
+/// GitHub pages every collection and links the next page from a Link header rather than from the
/// body, so a read that stopped at the first response would answer "the first hundred organizations" — a
/// different question from the one authorization is asking, and one whose wrong answer is a member being
/// refused for a reason nothing in the configuration explains.
diff --git a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_reading_membership.cs b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_reading_membership.cs
index 30af364..003e983 100644
--- a/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_reading_membership.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_GitHubMembershipClaimsEnricher/when_reading_membership.cs
@@ -15,7 +15,7 @@ namespace Cratis.AuthProxy.Authentication.for_GitHubMembershipClaimsEnricher;
///
///
/// A team claim is qualified by its organization because a slug is only unique within one: two
-/// organizations may both have a planner team, and an unqualified claim would let membership of
+/// organizations may both have a planner team, and an unqualified claim would let membership of
/// either satisfy a requirement written for one. The endpoints are derived from the configured user
/// endpoint, which is what lets GitHub Enterprise work without another setting.
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_a_single_provider_is_configured_with_an_explicit_return_url.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_a_single_provider_is_configured_with_an_explicit_return_url.cs
index 13966bb..8742130 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_a_single_provider_is_configured_with_an_explicit_return_url.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_a_single_provider_is_configured_with_an_explicit_return_url.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_request_is_for_the_selection_page_itself;
///
-/// When the caller already landed on /.cratis/select-provider?returnUrl=... — the wrapper the
+/// When the caller already landed on /.cratis/select-provider?returnUrl=... — the wrapper the
/// cookie authentication handler's redirect and the invite flow use — the real destination is the query
/// value, not the wrapper path around it. Regression coverage for a challenge that used to redirect back
/// to the wrapper URL instead of the caller's actual destination once this path stopped being skipped.
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_multiple_providers_are_configured.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_multiple_providers_are_configured.cs
index 9edf171..962c6f3 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_multiple_providers_are_configured.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_request_is_for_the_selection_page_itself/and_multiple_providers_are_configured.cs
@@ -6,10 +6,10 @@
namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_request_is_for_the_selection_page_itself;
///
-/// A request landing directly on /.cratis/select-provider — the way the cookie authentication
+/// A request landing directly on /.cratis/select-provider — the way the cookie authentication
/// handler's redirect and the invite flow both send an unauthenticated caller there — must be answered
/// by this middleware itself, not deferred to a later handler. Regression coverage: this path used to be
-/// treated as already-authentication-UI and skipped via next(), landing on a separate, unbranded
+/// treated as already-authentication-UI and skipped via next(), landing on a separate, unbranded
/// page instead of the one this middleware serves for every other unauthenticated request.
///
public class and_multiple_providers_are_configured : Specification
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_bearer_token_is_accepted.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_bearer_token_is_accepted.cs
index fce3760..19992c0 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_bearer_token_is_accepted.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_bearer_token_is_accepted.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_the_
///
/// A refusal must name a credential the caller can come back with when one exists.
///
-/// A 401 is required to carry a WWW-Authenticate challenge, and a bare one tells an
+/// A 401 is required to carry a WWW-Authenticate challenge, and a bare one tells an
/// integration only that it was refused — not that presenting a bearer token would have worked. With JWT
/// bearer configured the token is obtainable from the authority, so the challenge is real and actionable.
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_service_mints_its_own_tokens.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_service_mints_its_own_tokens.cs
index 8ae8654..614f092 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_service_mints_its_own_tokens.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_service_mints_its_own_tokens.cs
@@ -8,8 +8,8 @@ namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_the_
///
/// The bearer challenge is equally real when the token comes from AuthProxy itself.
///
-/// A service configured for back-channel client credentials can exchange them at /.cratis/token for
-/// a token this proxy accepts, so naming Bearer points the caller at a door that opens — with no
+/// A service configured for back-channel client credentials can exchange them at /.cratis/token for
+/// a token this proxy accepts, so naming Bearer points the caller at a door that opens — with no
/// external authority configured at all.
///
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_single_provider_is_configured.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_single_provider_is_configured.cs
index 90398c4..825c5dc 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_single_provider_is_configured.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_a_single_provider_is_configured.cs
@@ -8,9 +8,9 @@ namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_the_
///
/// With a single provider the refusal is an OIDC challenge — a redirect to the provider's login page —
/// which is the same silent success in a different shape: a client that follows redirects ends up reading
-/// the identity provider's login page as a 200, so the rejection is again invisible.
+/// the identity provider's login page as a 200, so the rejection is again invisible.
///
-/// A caller that is not navigating gets 401 instead, which is the answer it can actually act on.
+/// A caller that is not navigating gets 401 instead, which is the answer it can actually act on.
/// The challenge is still the right response to a browser navigating to a protected page, and that is
/// covered by .
///
diff --git a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_multiple_providers_are_configured.cs b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_multiple_providers_are_configured.cs
index d3bdfe8..ba215f0 100644
--- a/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_multiple_providers_are_configured.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_SelectProviderMiddleware/when_the_caller_is_not_navigating/and_multiple_providers_are_configured.cs
@@ -6,14 +6,14 @@
namespace Cratis.AuthProxy.Authentication.for_SelectProviderMiddleware.when_the_caller_is_not_navigating;
///
-/// A caller that is not navigating to a document must be refused with 401, not handed the
-/// provider-selection page at 200.
+/// A caller that is not navigating to a document must be refused with 401, not handed the
+/// provider-selection page at 200.
///
/// The status code is the defect, not the page. A webhook, an e-sign callback or any integration reads
-/// 200 as delivered and never retries; nothing errors, nothing is queued for redelivery. The same
-/// 200 defeats the conventional !response.ok check in every browser client — Arc's own
-/// identity bootstrap calls /.cratis/me and gets HTML with response.ok true, so only the
-/// subsequent .json() fails.
+/// 200 as delivered and never retries; nothing errors, nothing is queued for redelivery. The same
+/// 200 defeats the conventional !response.ok check in every browser client — Arc's own
+/// identity bootstrap calls /.cratis/me and gets HTML with response.ok true, so only the
+/// subsequent .json() fails.
///
///
/// The providers cookie is withheld with the page: it exists so the selection page can render the
diff --git a/Source/AuthProxy.Specs/Authentication/for_TenantAuthenticationState/when_the_return_url_is_hostile.cs b/Source/AuthProxy.Specs/Authentication/for_TenantAuthenticationState/when_the_return_url_is_hostile.cs
index c7dfbcd..e1b2b0c 100644
--- a/Source/AuthProxy.Specs/Authentication/for_TenantAuthenticationState/when_the_return_url_is_hostile.cs
+++ b/Source/AuthProxy.Specs/Authentication/for_TenantAuthenticationState/when_the_return_url_is_hostile.cs
@@ -4,19 +4,19 @@
namespace Cratis.AuthProxy.Authentication.for_TenantAuthenticationState;
///
-/// The returnUrl the login endpoint accepts must never survive as an off-site redirect.
+/// The returnUrl the login endpoint accepts must never survive as an off-site redirect.
///
/// This is the most exposed redirect sink in AuthProxy and the most valuable one to an attacker. The
-/// endpoint that feeds it — /.cratis/login/{scheme} — is AllowAnonymous, so the value is
-/// attacker-supplied by default. What it becomes is the challenge's RedirectUri,
-/// which ASP.NET's remote authentication handler hands straight to Response.Redirect once the
+/// endpoint that feeds it — /.cratis/login/{scheme} — is AllowAnonymous, so the value is
+/// attacker-supplied by default. What it becomes is the challenge's RedirectUri,
+/// which ASP.NET's remote authentication handler hands straight to Response.Redirect once the
/// identity provider returns, without validating it. The victim therefore sees a link on the real domain,
/// completes a genuine sign-in at the genuine provider, and only then lands wherever the link said —
/// every signal a careful person is taught to check having already passed.
///
///
-/// The check that used to guard it was returnUrl.StartsWith('/'), which //evil.test and
-/// /\evil.test both satisfy while navigating off-site. An absolute URL is reduced to its path and
+/// The check that used to guard it was returnUrl.StartsWith('/'), which //evil.test and
+/// /\evil.test both satisfy while navigating off-site. An absolute URL is reduced to its path and
/// query rather than refused, so a caller that sends its own origin keeps working — the host is dropped,
/// never honored.
///
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/given/an_access_control_middleware.cs b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/given/an_access_control_middleware.cs
index 48c8a3b..07916dd 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/given/an_access_control_middleware.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/given/an_access_control_middleware.cs
@@ -52,7 +52,7 @@ void Establish()
/// Builds the middleware over the current configuration.
///
///
- /// Deferred to the spec rather than done in Establish, so a spec can change the configuration
+ /// Deferred to the spec rather than done in Establish, so a spec can change the configuration
/// first — the options monitor captures the instance it is told about.
///
protected void BuildMiddleware()
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_does_not_satisfy_a_requirement.cs b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_does_not_satisfy_a_requirement.cs
index d639525..fdc8aca 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_does_not_satisfy_a_requirement.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_does_not_satisfy_a_requirement.cs
@@ -9,8 +9,8 @@ namespace Cratis.AuthProxy.Authorization.for_AccessControlMiddleware;
/// Two properties, and both matter. The request must not continue — everything downstream is tenancy,
/// identity resolution against a backend, and the reverse proxy, so continuing would mean the very caller
/// being refused had already caused a call into the application. And the answer must be a page at
-/// 403 rather than a redirect: the caller is authenticated, so sending them back to the
-/// identity provider signs them in again as the same person and loops forever. 403 is also a status
+/// 403 rather than a redirect: the caller is authenticated, so sending them back to the
+/// identity provider signs them in again as the same person and loops forever. 403 is also a status
/// a non-browser client can act on, so one answer serves both.
///
///
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_has_no_session.cs b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_has_no_session.cs
index 11ba344..a605de1 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_has_no_session.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_caller_has_no_session.cs
@@ -6,7 +6,7 @@ namespace Cratis.AuthProxy.Authorization.for_AccessControlMiddleware;
///
/// A caller with no session is left to the machinery that already refuses them.
///
-/// Refusing them here instead would replace a sign-in with a dead end: SelectProviderMiddleware
+/// Refusing them here instead would replace a sign-in with a dead end: SelectProviderMiddleware
/// answers an unauthenticated browser with the provider chooser or a challenge, and the
/// not-authorized page offers only signing out — of a session that does not exist. This gate is about who
/// a signed-in caller is, not about whether there is one.
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_path_is_anonymous.cs b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_path_is_anonymous.cs
index ff6176e..2cd6802 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_path_is_anonymous.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessControlMiddleware/when_the_path_is_anonymous.cs
@@ -10,7 +10,7 @@ namespace Cratis.AuthProxy.Authorization.for_AccessControlMiddleware;
/// This is not a loophole so much as the only coherent reading. A declared path exists precisely for
/// callers who have no session — a webhook receiver, a magic-link landing page, a signed-token report —
/// and a caller with no session carries no claims, so any requirement at all would refuse every one of
-/// them. A payment provider posting a webhook would get a 403 it cannot do anything about, and the
+/// them. A payment provider posting a webhook would get a 403 it cannot do anything about, and the
/// declaration that was supposed to make the path reachable would have been undone by a setting made
/// somewhere else entirely.
///
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_requirement_names_no_claim.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_requirement_names_no_claim.cs
index b34302c..f157138 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_requirement_names_no_claim.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_requirement_names_no_claim.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
///
/// Startup validation refuses such a configuration outright, so this is the second line rather than the
/// first — but it is the line that decides which way the failure falls. Discarding an unusable requirement
-/// is how an unusable AnonymousPaths entry is handled, and it is fail-closed there because
+/// is how an unusable AnonymousPaths entry is handled, and it is fail-closed there because
/// discarding leaves the path authenticated. Here the same move would leave the gate open, so the
/// direction has to be the other one: unsatisfiable means nobody satisfies it.
///
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_several_services_declare_different_requirements.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_several_services_declare_different_requirements.cs
index 7dcb69e..7736ec7 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_several_services_declare_different_requirements.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_several_services_declare_different_requirements.cs
@@ -9,8 +9,8 @@ namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
///
/// The gate runs before endpoint selection — it has to refuse a caller before a tenant is resolved or a
/// backend is called, and long before YARP picks a route — so it cannot ask which route was chosen and has
-/// to read the request the same way the route table will. That is the Service-ID header, then the
-/// service query parameter. If the two ever disagreed, one service's requirements would guard
+/// to read the request the same way the route table will. That is the Service-ID header, then the
+/// service query parameter. If the two ever disagreed, one service's requirements would guard
/// another service's traffic.
///
///
diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_the_claim_carries_one_of_the_allowed_values.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_the_claim_carries_one_of_the_allowed_values.cs
index aec372f..b405bde 100644
--- a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_the_claim_carries_one_of_the_allowed_values.cs
+++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_the_claim_carries_one_of_the_allowed_values.cs
@@ -8,8 +8,8 @@ namespace Cratis.AuthProxy.Authorization.for_AccessPolicy;
///
/// The values being matched are organization names, team slugs and role names — identifiers their own
/// systems treat as case-insensitive, and which an operator copies out of a URL or a settings page, where
-/// Cratis and cratis are the same thing. An ordinal comparison would turn that into a
-/// deployment nobody can sign in to, with a 403 that says nothing about why.
+/// Cratis and cratis are the same thing. An ordinal comparison would turn that into a
+/// deployment nobody can sign in to, with a 403 that says nothing about why.
///
///
/// Also pinned here: a caller carrying several claims of the same type — the ordinary case for someone in
diff --git a/Source/AuthProxy.Specs/Configuration/for_Admission/when_using_defaults.cs b/Source/AuthProxy.Specs/Configuration/for_Admission/when_using_defaults.cs
index 9aa5854..d36134c 100644
--- a/Source/AuthProxy.Specs/Configuration/for_Admission/when_using_defaults.cs
+++ b/Source/AuthProxy.Specs/Configuration/for_Admission/when_using_defaults.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Configuration.for_Admission;
///
///
/// The entry lifetime is the one with a reason outside itself: it has to outlast ASP.NET Core's own
-/// fifteen-minute RemoteAuthenticationTimeout, because an entry that expires while the framework
+/// fifteen-minute RemoteAuthenticationTimeout, because an entry that expires while the framework
/// still considers the handshake live returns the caller to the uniform refusal with nothing anywhere to
/// diagnose it from. The Aspire mirror pins the same number from the other side; without this the half that
/// actually runs in production could be moved back under that bound with every spec still green.
diff --git a/Source/AuthProxy.Specs/ErrorPages/for_ErrorPageProvider/when_a_well_known_page_has_no_shipped_default.cs b/Source/AuthProxy.Specs/ErrorPages/for_ErrorPageProvider/when_a_well_known_page_has_no_shipped_default.cs
index 88fa452..7847428 100644
--- a/Source/AuthProxy.Specs/ErrorPages/for_ErrorPageProvider/when_a_well_known_page_has_no_shipped_default.cs
+++ b/Source/AuthProxy.Specs/ErrorPages/for_ErrorPageProvider/when_a_well_known_page_has_no_shipped_default.cs
@@ -6,12 +6,12 @@
namespace Cratis.AuthProxy.ErrorPages.for_ErrorPageProvider;
///
-/// Every page the proxy can ask for must exist in the shipped Pages folder.
+/// Every page the proxy can ask for must exist in the shipped Pages folder.
///
/// A missing one is not an error anywhere — falls back to a minimal inline
/// document, so the deployment gets a bare "Error 200" heading where the sign-in chooser belongs and
-/// nothing reports why. That is exactly how select-provider.html came to be absent while its twin
-/// invitation-select-provider.html shipped: both are listed in the documentation as defaults, and
+/// nothing reports why. That is exactly how select-provider.html came to be absent while its twin
+/// invitation-select-provider.html shipped: both are listed in the documentation as defaults, and
/// only one of them was there.
///
///
diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_setting_identity_headers_for_a_safe_name.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_setting_identity_headers_for_a_safe_name.cs
index 40aae1b..1af9ba9 100644
--- a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_setting_identity_headers_for_a_safe_name.cs
+++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_setting_identity_headers_for_a_safe_name.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions;
///
/// A safe name is written exactly as it always was, and any sibling header left behind by an earlier
-/// caller is removed rather than left to contradict it. A stale x-ms-client-principal-name* would
+/// caller is removed rather than left to contradict it. A stale x-ms-client-principal-name* would
/// tell a backend the plain header is encoded when it is not, which is a name change by omission.
///
public class when_setting_identity_headers_for_a_safe_name : Specification
diff --git a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_outside_ascii/and_it_has_control_characters.cs b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_outside_ascii/and_it_has_control_characters.cs
index 6ce6ebf..c5b43a5 100644
--- a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_outside_ascii/and_it_has_control_characters.cs
+++ b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_outside_ascii/and_it_has_control_characters.cs
@@ -4,9 +4,9 @@
namespace Cratis.AuthProxy.Identity.for_HeaderValue.when_encoding_a_value_outside_ascii;
///
-/// A carriage return, a line feed and a NUL are all below U+0080, so an encoder that asked only
+/// A carriage return, a line feed and a NUL are all below U+0080, so an encoder that asked only
/// "is this ASCII?" would pass them straight through and let a claim value write its own header. The
-/// encoder emits nothing but RFC 8187 attr-char octets, which makes that structurally impossible
+/// encoder emits nothing but RFC 8187 attr-char octets, which makes that structurally impossible
/// rather than merely checked for.
///
public class and_it_has_control_characters : Specification
diff --git a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_hides_a_line_break_behind_the_prefix.cs b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_hides_a_line_break_behind_the_prefix.cs
index a031a4d..ab98cd0 100644
--- a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_hides_a_line_break_behind_the_prefix.cs
+++ b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_hides_a_line_break_behind_the_prefix.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Identity.for_HeaderValue.when_encoding_a_value_that_a
///
/// The attack the prefix rule exists for. Setting a provider display name to
-/// UTF-8''victim%0D%0AX-Admin:%20true is printable US-ASCII throughout, so it used to travel byte for
+/// UTF-8''victim%0D%0AX-Admin:%20true is printable US-ASCII throughout, so it used to travel byte for
/// byte with no sibling — and a backend following the published decode snippet, which branched on the
/// prefix, decoded it and obtained a carriage return, a line feed and a header of the caller's choosing.
/// CR and LF were structurally impossible to emit and perfectly possible to reconstruct.
diff --git a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_is_an_ordinary_name_behind_the_prefix.cs b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_is_an_ordinary_name_behind_the_prefix.cs
index 980afa0..500c65d 100644
--- a/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_is_an_ordinary_name_behind_the_prefix.cs
+++ b/Source/AuthProxy.Specs/Identity/for_HeaderValue/when_encoding_a_value_that_already_looks_encoded/and_it_is_an_ordinary_name_behind_the_prefix.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Identity.for_HeaderValue.when_encoding_a_value_that_a
/// A display name is whatever the person typed, and nothing stops them typing the charset prefix. Every
/// character of it is printable US-ASCII, so a rule that asked only "is this ASCII?" sent it verbatim and
/// emitted no sibling — leaving the plain header carrying something indistinguishable from an
-/// ext-value and nothing anywhere saying which it was. Encoding it makes the two answerable
+/// ext-value and nothing anywhere saying which it was. Encoding it makes the two answerable
/// separately: the sibling says it is encoded, and decoding gives back exactly what was typed.
///
public class and_it_is_an_ordinary_name_behind_the_prefix : Specification
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityAccountBinding/when_the_legacy_user_id_is_whitespace.cs b/Source/AuthProxy.Specs/Identity/for_IdentityAccountBinding/when_the_legacy_user_id_is_whitespace.cs
index bfb2a7b..dfd39fc 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityAccountBinding/when_the_legacy_user_id_is_whitespace.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityAccountBinding/when_the_legacy_user_id_is_whitespace.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityAccountBinding;
/// Specifies that a legacy principal with a whitespace-only user identifier never becomes a reusable binding.
///
///
-/// Whitespace round-trips through IsNullOrWhiteSpace checks that only look for an empty string, so it
+/// Whitespace round-trips through IsNullOrWhiteSpace checks that only look for an empty string, so it
/// is exactly the kind of value that used to slip through and become a shared cache/lock key for every
/// caller whose provider omitted a subject claim.
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityAuthorizationCache/when_recording_with_verification_required/given/a_verifying_deployment.cs b/Source/AuthProxy.Specs/Identity/for_IdentityAuthorizationCache/when_recording_with_verification_required/given/a_verifying_deployment.cs
index b7220d5..659ef9e 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityAuthorizationCache/when_recording_with_verification_required/given/a_verifying_deployment.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityAuthorizationCache/when_recording_with_verification_required/given/a_verifying_deployment.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Identity.for_IdentityAuthorizationCache.when_recording_with_verification_required.given;
///
-/// Provides an authorization cache for a deployment whose one service answers /.cratis/me with an
+/// Provides an authorization cache for a deployment whose one service answers /.cratis/me with an
/// authorization verdict, with the re-validation interval left for each spec to state.
///
public class a_verifying_deployment : Specification
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_cookie_is_already_present.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_cookie_is_already_present.cs
index 2d751b5..d6722f0 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_cookie_is_already_present.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_cookie_is_already_present.cs
@@ -7,13 +7,13 @@
namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver;
///
-/// The readable .cratis-identity cookie must not, on its own, decide that a caller is authorized.
+/// The readable .cratis-identity cookie must not, on its own, decide that a caller is authorized.
///
/// It is written non-HTTP-only so a frontend can render the signed-in user from it, which means script on
/// any proxied origin can write it — and a non-browser caller can simply send it. This resolver used to
/// short-circuit on its mere presence, without ever reading its value, so
-/// Cookie: .cratis-identity=x alongside a valid session was enough to skip every configured
-/// service's /.cratis/me authorization call, for as long as the caller kept sending it. A user
+/// Cookie: .cratis-identity=x alongside a valid session was enough to skip every configured
+/// service's /.cratis/me authorization call, for as long as the caller kept sending it. A user
/// whose backend authorization had been revoked stayed authorized at the proxy by choice.
///
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_a_negative_verdict.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_a_negative_verdict.cs
index fbe2356..53a6237 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_a_negative_verdict.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_a_negative_verdict.cs
@@ -8,9 +8,9 @@ namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_ve
///
/// The compatibility fence for the default mode. The released proxy read no verdict out of a successful
-/// body at all — it took details and forwarded the caller — so a service answering the full envelope
-/// with isAuthorized: false was admitted and enriched. That is the exact shape
-/// IdentityProviderResult serializes, so it is what a service reaching for the documented response
+/// body at all — it took details and forwarded the caller — so a service answering the full envelope
+/// with isAuthorized: false was admitted and enriched. That is the exact shape
+/// IdentityProviderResult serializes, so it is what a service reaching for the documented response
/// type writes, and there are deployments answering it today for reasons that have nothing to do with
/// authorization: an account mid-onboarding, a trial that has lapsed into read-only, a profile the
/// application renders a banner for.
@@ -18,7 +18,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_ve
/// Reading it as a refusal here would take those deployments down on an upgrade, without a configuration
/// change and without anything to look at but a forbidden page. A deployment that wants the verdict
/// enforced asks for it with , and
-/// when_identity_verification_is_required/and_the_service_refuses_the_caller is the same body pinned
+/// when_identity_verification_is_required/and_the_service_refuses_the_caller is the same body pinned
/// on the other side of that choice.
///
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_forbidden.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_forbidden.cs
index 4c0d770..b9ef666 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_forbidden.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_best_effort/and_the_service_answers_forbidden.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_ve
///
/// The other half of the fence, and the reason the default mode is not simply "admit everything": an HTTP
-/// 403 is the one answer the released proxy refused on, and it still refuses on it. Without this the
+/// 403 is the one answer the released proxy refused on, and it still refuses on it. Without this the
/// pair of specs would read as though the default mode had no denial at all, and a later change removing the
/// last one would look like a simplification.
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_an_unsuccessful_status.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_an_unsuccessful_status.cs
index 12bd446..6d90a70 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_an_unsuccessful_status.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_an_unsuccessful_status.cs
@@ -6,9 +6,9 @@
namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_verification_is_required;
///
-/// Every non-success status other than 403 used to be logged and then ignored, so a bad request, an
+/// Every non-success status other than 403 used to be logged and then ignored, so a bad request, an
/// expired back-channel credential, a routing mistake and a whole backend being down were all as good as a
-/// yes. Only 403 denied, which made the safe answer depend on a misconfigured service choosing
+/// yes. Only 403 denied, which made the safe answer depend on a misconfigured service choosing
/// exactly the right code to refuse with.
///
/// Written across the range rather than one code per file because the behavior under specification is a
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_nothing_to_read.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_nothing_to_read.cs
index 3e78978..022db3f 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_nothing_to_read.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_nothing_to_read.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_ve
///
/// A successful status with nothing in it is the most misleading of the failure shapes: it looks like the
-/// happy path to every layer that only checks the status code. 204 No Content, an empty body and a
+/// happy path to every layer that only checks the status code. 204 No Content, an empty body and a
/// body of whitespace all state a verdict nowhere, so none of them may be read as one.
///
public class and_the_service_answers_nothing_to_read : given.a_required_verification_resolver
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_unparseable_json.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_unparseable_json.cs
index 51d0a1f..6e121e4 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_unparseable_json.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_answers_unparseable_json.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_ve
///
/// A body that will not parse is the classic shape of an intercepting proxy, a captive portal, or a load
-/// balancer's own error page arriving at 200. The released code caught the parse failure and carried
+/// balancer's own error page arriving at 200. The released code caught the parse failure and carried
/// on with empty details, so an HTML sign-in page from somebody else's infrastructure authorized the caller.
///
public class and_the_service_answers_unparseable_json : given.a_required_verification_resolver
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_refuses_the_caller.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_refuses_the_caller.cs
index b3a09c8..b45ccba 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_refuses_the_caller.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/and_the_service_refuses_the_caller.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_verification_is_required;
///
-/// An outright refusal still denies, and now in both of the ways a service can express it: the 403
+/// An outright refusal still denies, and now in both of the ways a service can express it: the 403
/// that used to be the only path to a denial at all, and a well-formed body whose verdict is no. The second
/// is what a service reaching for the documented response shape actually writes.
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/given/a_required_verification_resolver.cs b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/given/a_required_verification_resolver.cs
index 312bf1d..f18951a 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/given/a_required_verification_resolver.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityDetailsResolver/when_identity_verification_is_required/given/a_required_verification_resolver.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Identity.for_IdentityDetailsResolver.when_identity_verification_is_required.given;
///
-/// Provides a resolver for a deployment whose one service answers /.cratis/me with an authorization
+/// Provides a resolver for a deployment whose one service answers /.cratis/me with an authorization
/// verdict, plus a scriptable identity endpoint to answer with.
///
///
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs
index b0cf010..6bb29e2 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/given/an_identity_middleware.cs
@@ -15,7 +15,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.given;
/// Everything is left mutable so a spec states only the one thing it is about: whether a tenant reached
/// , what the deployment requires, and what the path is. The resolver is a
/// substitute rather than the real one because the question here is not what a verdict is worth — that is
-/// for_IdentityDetailsResolver's — but whether the verdict is asked for at all.
+/// for_IdentityDetailsResolver's — but whether the verdict is asked for at all.
///
public class an_identity_middleware : Specification
{
diff --git a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_the_path_is_declared_anonymous.cs b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_the_path_is_declared_anonymous.cs
index f917d10..a490033 100644
--- a/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_the_path_is_declared_anonymous.cs
+++ b/Source/AuthProxy.Specs/Identity/for_IdentityMiddleware/when_no_tenant_resolves/and_the_path_is_declared_anonymous.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Identity.for_IdentityMiddleware.when_no_tenant_resolv
///
/// The one deliberate exemption, pinned so it stays deliberate. A path a service lists in
-/// AnonymousPaths is declared to be served without a session at all — a magic-link landing page, a
+/// AnonymousPaths is declared to be served without a session at all — a magic-link landing page, a
/// signed-token report, a public webhook receiver — so demanding an identity verdict for it would refuse
/// exactly what the declaration exists to permit. The application stays responsible for authorizing those
/// paths, which is what the setting already says.
diff --git a/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_user_has_a_name_outside_ascii.cs b/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_user_has_a_name_outside_ascii.cs
index 5f1c6a4..9dc8aad 100644
--- a/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_user_has_a_name_outside_ascii.cs
+++ b/Source/AuthProxy.Specs/Identity/for_InjectIdentityHeadersTransform/when_user_has_a_name_outside_ascii.cs
@@ -6,10 +6,10 @@
namespace Cratis.AuthProxy.Identity.for_InjectIdentityHeadersTransform;
///
-/// A display name is whatever the provider says it is, and providers say things like Søren Wærstad.
+/// A display name is whatever the provider says it is, and providers say things like Søren Wærstad.
/// Such a value cannot be written to a header field at all, so before this the proxied request failed at
/// the gateway and the person could not use the application. It now travels as an RFC 8187
-/// ext-value, announced by the starred sibling, with the exact original still in the client
+/// ext-value, announced by the starred sibling, with the exact original still in the client
/// principal.
///
public class when_user_has_a_name_outside_ascii : Specification
diff --git a/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_matching_a_peer_against_declared_proxies.cs b/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_matching_a_peer_against_declared_proxies.cs
index 33e2c69..a139910 100644
--- a/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_matching_a_peer_against_declared_proxies.cs
+++ b/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_matching_a_peer_against_declared_proxies.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Ingress.for_TrustedProxyPolicy;
///
///
/// The IPv4-mapped case is the one that silently ruins a correct configuration: a dual-stack listener reports
-/// an IPv4 peer as ::ffff:10.0.0.7, so an operator who declared 10.0.0.0/8 would see none of
+/// an IPv4 peer as ::ffff:10.0.0.7, so an operator who declared 10.0.0.0/8 would see none of
/// their own traffic match, conclude the setting does not work, and reach for the allow-all fallback.
///
public class when_matching_a_peer_against_declared_proxies : a_trusted_proxy_policy
diff --git a/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_only_loopback_is_trusted/and_the_boundary_is_applied.cs b/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_only_loopback_is_trusted/and_the_boundary_is_applied.cs
index aa1909b..c707b62 100644
--- a/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_only_loopback_is_trusted/and_the_boundary_is_applied.cs
+++ b/Source/AuthProxy.Specs/Ingress/for_TrustedProxyPolicy/when_only_loopback_is_trusted/and_the_boundary_is_applied.cs
@@ -8,10 +8,10 @@
namespace Cratis.AuthProxy.Ingress.for_TrustedProxyPolicy.when_only_loopback_is_trusted;
///
-/// LoopbackOnly used to be expressed by returning without touching the options, on the reasoning that
+/// LoopbackOnly used to be expressed by returning without touching the options, on the reasoning that
/// loopback is the framework's own default and there was nothing to add. That reasoning holds only while the
-/// defaults are still there, and they are not always: ASPNETCORE_FORWARDEDHEADERS_ENABLED, the
-/// standard switch for containerized ASP.NET images, has ConfigureWebDefaults clear both lists — and
+/// defaults are still there, and they are not always: ASPNETCORE_FORWARDEDHEADERS_ENABLED, the
+/// standard switch for containerized ASP.NET images, has ConfigureWebDefaults clear both lists — and
/// the forwarded-headers middleware treats two empty lists as "believe every caller", the exact opposite of
/// the mode's name. A mode that names a boundary has to write the boundary down.
///
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_attested_invite_completion.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_attested_invite_completion.cs
index f1e4b13..6200002 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_attested_invite_completion.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_attested_invite_completion.cs
@@ -5,6 +5,7 @@
using System.Security.Cryptography;
using System.Text;
using Cratis.AuthProxy.Authentication;
+using Cratis.AuthProxy.given;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
@@ -30,6 +31,8 @@ public class an_attested_invite_completion : Specification
protected RecordingAttestationIssuer _attestationIssuer;
protected RecordingHandler _handler;
protected IErrorPageProvider _errorPageProvider;
+ protected RecordingLogger _logger;
+ protected string _inviteToken;
protected bool _nextCalled;
protected virtual bool InvitationCompletionEnabled => true;
@@ -37,10 +40,25 @@ public class an_attested_invite_completion : Specification
protected virtual bool IncludeVerifiedEmailClaims => true;
protected virtual IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+ /// Gets the challenge the established session actually answered.
+ protected virtual string SessionChallenge => Challenge;
+
+ ///
+ /// Gets the invited-email claim type the options monitor starts publishing the moment the completion
+ /// awaits the session, or to keep publishing the staged configuration.
+ ///
+ protected virtual string? ReloadedEmailClaimDuringSession => null;
+
+ ///
+ /// Gets a value indicating whether the options monitor stops publishing any invite configuration at all
+ /// the moment the completion awaits the session.
+ ///
+ protected virtual bool RemovesInviteDuringSession => false;
+
void Establish()
{
var (inviteSigningKey, publicKeyPem) = TokenFixture.GenerateKeyPair();
- var inviteConfig = new C.AuthProxy
+ C.AuthProxy CreateConfiguration(string emailClaim) => new()
{
Invite = new C.Invite
{
@@ -48,14 +66,16 @@ void Establish()
Issuer = InviteIssuer,
Audience = InviteAudience,
TenantClaim = InvitationAttestationClaims.TenantId,
- EmailClaim = InvitationAttestationClaims.Email,
+ EmailClaim = emailClaim,
ExchangeUrl = ExchangeUrl,
StageUrl = "https://lobby.example.com/_invite/stage",
Attestation = new C.InvitationAttestation(),
}
};
+
+ var published = CreateConfiguration(InvitationAttestationClaims.Email);
var inviteOptions = Substitute.For>();
- inviteOptions.CurrentValue.Returns(inviteConfig);
+ inviteOptions.CurrentValue.Returns(_ => published);
var authentication = new C.Authentication
{
@@ -97,12 +117,26 @@ void Establish()
IssuedUtc = new DateTimeOffset(2026, 8, 10, 1, 2, 3, TimeSpan.Zero),
};
properties.Items[InvitationAuthenticationState.TransactionStateKey] = Transaction;
- properties.Items[InvitationAuthenticationState.ChallengeStateKey] = Challenge;
+ properties.Items[InvitationAuthenticationState.ChallengeStateKey] = SessionChallenge;
properties.Items[InvitationAuthenticationState.CapabilityHashStateKey] = CapabilityHash;
+ // The only await between resolving the entry state and issuing the attestation. A configuration
+ // reload published here is the exact race a completion stage that re-reads configuration loses.
var authenticationService = Substitute.For();
authenticationService.AuthenticateAsync(Arg.Any(), CookieAuthenticationDefaults.AuthenticationScheme)
- .Returns(AuthenticateResult.Success(new AuthenticationTicket(principal, properties, CookieAuthenticationDefaults.AuthenticationScheme)));
+ .Returns(_ =>
+ {
+ if (RemovesInviteDuringSession)
+ {
+ published = new C.AuthProxy();
+ }
+ else if (ReloadedEmailClaimDuringSession is { } reloadedEmailClaim)
+ {
+ published = CreateConfiguration(reloadedEmailClaim);
+ }
+
+ return AuthenticateResult.Success(new AuthenticationTicket(principal, properties, CookieAuthenticationDefaults.AuthenticationScheme));
+ });
_context = new DefaultHttpContext
{
@@ -123,6 +157,7 @@ void Establish()
new Claim(InvitationAttestationClaims.TenantId, TenantId),
.. InvitationClaims,
]);
+ _inviteToken = token;
properties.Items[InvitationAuthenticationState.CapabilityHashStateKey] = ComputeHash(token);
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}={token}; {Cookies.InvitationEntryState}=protected-state";
InvitationSessionFixture.GivenSessionEstablishedByTheInvitation(_context, token);
@@ -147,6 +182,7 @@ void Establish()
httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(_handler));
_errorPageProvider = Substitute.For();
_errorPageProvider.WriteErrorPageAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask);
+ _logger = new();
_middleware = new(
_ =>
@@ -160,7 +196,7 @@ void Establish()
Substitute.For(),
httpClientFactory,
_errorPageProvider,
- Substitute.For>(),
+ _logger,
new CanonicalIdentityResolver(authenticationOptions),
_attestationIssuer,
protector);
@@ -195,11 +231,14 @@ protected sealed class RecordingHandler : HttpMessageHandler
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
+ /// Gets or sets the body the exchange endpoint answers with.
+ public string ResponseBody { get; set; } = string.Empty;
+
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
Body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken);
- return new HttpResponseMessage(StatusCode);
+ return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invitation_entry.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invitation_entry.cs
index 05a20f8..5aeb481 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invitation_entry.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invitation_entry.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.given;
///
/// Reusable context that drives through Phase 1 — the invitation link itself
-/// arriving on /invite/{token} — with identity providers configured, so a specification can say what
+/// arriving on /invite/{token} — with identity providers configured, so a specification can say what
/// the caller is offered before any provider handshake has happened.
///
public class an_invitation_entry : Specification
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs
index 8b5bf0d..2a0bd66 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.given;
/// Reusable context that drives through its Phase-2 (post-login)
/// invite exchange using a real and a signed invite token,
/// so re-validation and email binding are actually exercised. Concrete specs set the pending invite
-/// cookie and the authenticated user in their own Establish, then invoke the middleware.
+/// cookie and the authenticated user in their own Establish, then invoke the middleware.
///
public class an_invite_exchange : Specification
{
@@ -96,8 +96,8 @@ void Establish()
/// needs to forge, expire, or otherwise diverge from a valid token.
///
/// The key to sign with; defaults to the configured (trusted) key.
- /// The iss claim; defaults to the expected issuer.
- /// The aud claim; defaults to the expected audience.
+ /// The iss claim; defaults to the expected issuer.
+ /// The aud claim; defaults to the expected audience.
/// The expiry time; defaults to one hour from now.
/// The earliest valid time; defaults to one minute ago.
/// Additional claims to embed in the token.
@@ -112,9 +112,9 @@ protected string CreateSignedToken(
TokenFixture.CreateToken(signingKey ?? _signingKey, issuer, audience, expires, notBefore, claims);
///
- /// Marks the request as authenticated, always carrying a sub claim plus any supplied claims.
+ /// Marks the request as authenticated, always carrying a sub claim plus any supplied claims.
///
- /// Additional claims (e.g. email, email_verified) for the account.
+ /// Additional claims (e.g. email, email_verified) for the account.
protected void GivenAuthenticatedUserWith(params Claim[] claims) =>
_context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.Prepend(new Claim("sub", "user-123")), "aad"));
@@ -123,7 +123,7 @@ protected void GivenAuthenticatedUserWith(params Claim[] claims) =>
/// invitation's own challenge, exactly as a Phase-2 request arrives once the provider has signed the
/// caller in for the invitation.
///
- /// The token to carry in the .cratis-invite cookie.
+ /// The token to carry in the .cratis-invite cookie.
protected void GivenPendingInviteCookie(string token)
{
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}={token}";
@@ -134,7 +134,7 @@ protected void GivenPendingInviteCookie(string token)
/// Places the given token in the pending invite cookie while the caller is signed in from before the
/// invitation was ever opened — the browser that already had a session with another provider.
///
- /// The token to carry in the .cratis-invite cookie.
+ /// The token to carry in the .cratis-invite cookie.
protected void GivenPendingInviteCookieOnAPreExistingSession(string token)
{
_context.Request.Headers.Cookie = $"{Cookies.InviteToken}={token}";
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange_with_recorded_logs.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange_with_recorded_logs.cs
index 33cb0f9..2b8dd77 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange_with_recorded_logs.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/given/an_invite_exchange_with_recorded_logs.cs
@@ -51,7 +51,7 @@ protected void GivenInvitationRequestFor(string capability) =>
///
/// Marks the request as authenticated by a legacy provider account whose subject is the sentinel.
///
- /// Additional claims (e.g. email, email_verified) for the account.
+ /// Additional claims (e.g. email, email_verified) for the account.
protected void GivenLegacyAuthenticatedUserWith(params Claim[] claims) =>
_context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.Prepend(new Claim("sub", SensitiveSubject)), "aad"));
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_only_address_is_the_preferred_username.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_only_address_is_the_preferred_username.cs
index d2b0c0a..bad2214 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_only_address_is_the_preferred_username.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_only_address_is_the_preferred_username.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email;
///
-/// Some OIDC providers put the account's address in preferred_username — Entra's is the user principal
+/// Some OIDC providers put the account's address in preferred_username — Entra's is the user principal
/// name. The binding still has to accept it: what disqualifies the claim is holding a username, not the claim
/// it arrived in. This is the control for
/// , which differs only in the shape of the value.
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_provider_supplies_no_email_address.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_provider_supplies_no_email_address.cs
index fc85de6..4d12e7e 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_provider_supplies_no_email_address.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_binding_the_invite_to_the_invited_email/and_the_provider_supplies_no_email_address.cs
@@ -4,8 +4,8 @@
namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_binding_the_invite_to_the_invited_email;
///
-/// A GitHub account whose email is private supplies no address at all: /user returns a null email, so
-/// no email claim is written and only preferred_username — the login name — is left. That is not
+/// A GitHub account whose email is private supplies no address at all: /user returns a null email, so
+/// no email claim is written and only preferred_username — the login name — is left. That is not
/// an address, and reporting it as a mismatch names a specific, wrong cause: the account and the address are
/// both the right ones. No address available and a different person's address are the two
/// cases a binding has to tell apart.
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_different_verified_email.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_different_verified_email.cs
new file mode 100644
index 0000000..55c6115
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_different_verified_email.cs
@@ -0,0 +1,33 @@
+// 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.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied a verified email, but it belongs to a different account than the one the
+/// invitation was issued for. This is a specific, actionable outcome distinct from
+/// - the account and address are
+/// both real, they are just the wrong ones - and must not be collapsed into a generic invalid-link denial.
+///
+public class when_completing_an_attested_invitation_with_a_different_verified_email : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ var emailClaim = identity.FindFirst("email")!;
+ identity.RemoveClaim(emailClaim);
+ identity.AddClaim(new Claim("email", "someone-else@example.com"));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+ [Fact] void should_not_serve_the_email_unavailable_page() => _errorPageProvider.DidNotReceive().WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailUnavailable, Arg.Any());
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_email_claim.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_email_claim.cs
new file mode 100644
index 0000000..a1cdab0
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_email_claim.cs
@@ -0,0 +1,29 @@
+// 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.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied two conflicting email claims. A duplicated claim is not a single,
+/// trustworthy address - the attested protocol requires exactly one - so this is evaluated exactly like no
+/// address at all: the dedicated unavailable-address outcome, never a value picked from the ambiguous pair.
+///
+public class when_completing_an_attested_invitation_with_a_duplicate_email_claim : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.AddClaim(new Claim("email", "someone-else@example.com"));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_unavailable_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailUnavailable, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_verification_claim.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_verification_claim.cs
new file mode 100644
index 0000000..5631108
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_duplicate_verification_claim.cs
@@ -0,0 +1,30 @@
+// 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.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied the invited address itself, but two conflicting email_verified
+/// claims. A duplicated claim is not single, trustworthy verification evidence - the attested protocol
+/// requires exactly one - and is answered with the same actionable mismatch outcome as an explicit
+/// , never a silent success picked from an ambiguous pair.
+///
+public class when_completing_an_attested_invitation_with_a_duplicate_verification_claim : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.AddClaim(new Claim("email_verified", bool.FalseString.ToLowerInvariant()));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_malformed_verification_claim.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_malformed_verification_claim.cs
new file mode 100644
index 0000000..44e9aa1
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_malformed_verification_claim.cs
@@ -0,0 +1,30 @@
+// 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.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied the invited address itself, but its email_verified claim does not
+/// parse as a boolean at all. An unparseable claim is not verification evidence, and is answered with the
+/// same actionable mismatch outcome as an explicit - never a silent success.
+///
+public class when_completing_an_attested_invitation_with_a_malformed_verification_claim : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.RemoveClaim(identity.FindFirst("email_verified"));
+ identity.AddClaim(new Claim("email_verified", "maybe"));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_missing_verification_claim.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_missing_verification_claim.cs
new file mode 100644
index 0000000..a05aeed
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_a_missing_verification_claim.cs
@@ -0,0 +1,30 @@
+// 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.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied the invited address itself but no email_verified claim at all. The
+/// attested protocol never treats an absent verification claim as an unknown it can skip past - it is
+/// evaluated as unverified, exactly like an explicit , so this is answered with the
+/// same actionable mismatch outcome and never with a silent success.
+///
+public class when_completing_an_attested_invitation_with_a_missing_verification_claim : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.RemoveClaim(identity.FindFirst("email_verified"));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_an_unverified_email.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_an_unverified_email.cs
new file mode 100644
index 0000000..6d36fdf
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_with_an_unverified_email.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.
+
+using Cratis.AuthProxy.Invites.for_InviteMiddleware.given;
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware;
+
+///
+/// The identity provider supplied the invited address itself, but explicitly flagged it as unverified. An
+/// unverified claim of the right address is not trusted evidence, and is answered with the same actionable
+/// mismatch outcome as a verified but different address - never a generic invalid-link denial.
+///
+public class when_completing_an_attested_invitation_with_an_unverified_email : an_attested_invite_completion
+{
+ protected override IReadOnlyList InvitationClaims => [new(InvitationAttestationClaims.Email, Email)];
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ var verifiedClaim = identity.FindFirst("email_verified")!;
+ identity.RemoveClaim(verifiedClaim);
+ identity.AddClaim(new Claim("email_verified", bool.FalseString.ToLowerInvariant()));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_without_verified_email.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_without_verified_email.cs
index 07258cd..ae2330c 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_without_verified_email.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_completing_an_attested_invitation_without_verified_email.cs
@@ -23,5 +23,10 @@ async Task Because()
[Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
[Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
[Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
- [Fact] void should_serve_a_branded_denial() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationInvalid, StatusCodes.Status403Forbidden);
+
+ ///
+ /// The identity provider supplied no usable email at all, so the invitee sees the dedicated
+ /// unavailable-address outcome instead of a generic invalid-link denial that offers no real recovery.
+ ///
+ [Fact] void should_serve_the_email_unavailable_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailUnavailable, StatusCodes.Status403Forbidden);
}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_challenge_binding_mismatch.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_challenge_binding_mismatch.cs
new file mode 100644
index 0000000..38d3d02
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_challenge_binding_mismatch.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_InviteMiddleware.when_logging_an_attested_invitation;
+
+///
+/// The session answered a different challenge than the one this invitation staged. A second stage, refused
+/// for a second reason, proves the reason travels from wherever the guard sits rather than one stage having
+/// been wired up by hand — and the challenge values themselves stay out of the log.
+///
+public class with_a_challenge_binding_mismatch : given.an_attested_invite_completion
+{
+ protected override string SessionChallenge => "a-challenge-from-another-invitation";
+
+ async Task Because() => await _middleware.InvokeAsync(_context);
+
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_record_the_bounded_stage_reason() => _logger.Text.ShouldContain(nameof(InvitationCompletionFailureReason.ChallengeBindingMismatch));
+ [Fact] void should_not_record_the_other_stages_reason() => _logger.Text.ShouldNotContain(nameof(InvitationCompletionFailureReason.EntryStateUnprotectFailed));
+ [Fact] void should_not_disclose_the_capability() => _logger.Text.ShouldNotContain(_inviteToken);
+ [Fact] void should_not_disclose_the_expected_challenge() => _logger.Text.ShouldNotContain(Challenge);
+ [Fact] void should_not_disclose_the_presented_challenge() => _logger.Text.ShouldNotContain(SessionChallenge);
+ [Fact] void should_not_disclose_the_invited_email() => _logger.Text.ShouldNotContain(Email);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_corrupted_entry_state.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_corrupted_entry_state.cs
new file mode 100644
index 0000000..6a839fc
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_corrupted_entry_state.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_logging_an_attested_invitation;
+
+///
+/// The protected invitation-entry state the browser presents does not unprotect. The whole point of the
+/// bounded reason is that an operator can tell this stage apart from every other refusal, so the stage is
+/// named in the log — and nothing else about the request is.
+///
+public class with_a_corrupted_entry_state : given.an_attested_invite_completion
+{
+ async Task Because()
+ {
+ _context.Request.Headers.Cookie = $"{Cookies.InviteToken}={_inviteToken}; {Cookies.InvitationEntryState}=corrupted-not-a-real-protected-payload";
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_record_the_bounded_stage_reason() => _logger.Text.ShouldContain(nameof(InvitationCompletionFailureReason.EntryStateUnprotectFailed));
+ [Fact] void should_not_disclose_the_capability() => _logger.Text.ShouldNotContain(_inviteToken);
+ [Fact] void should_not_disclose_the_invited_email() => _logger.Text.ShouldNotContain(Email);
+ [Fact] void should_not_disclose_the_invitation_transaction() => _logger.Text.ShouldNotContain(Transaction);
+ [Fact] void should_not_disclose_the_invitation_challenge() => _logger.Text.ShouldNotContain(Challenge);
+ [Fact] void should_not_disclose_the_provider_subject() => _logger.Text.ShouldNotContain("provider-subject");
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_duplicate_subject_response.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_duplicate_subject_response.cs
new file mode 100644
index 0000000..8caf43d
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_attested_invitation/with_a_duplicate_subject_response.cs
@@ -0,0 +1,30 @@
+// 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_logging_an_attested_invitation;
+
+///
+/// The exchange endpoint answers 409: the attested subject already belongs to a user. The attested
+/// protocol records that collision exactly as the legacy exchange does, and the backend's response body -
+/// whatever it happens to carry - is never part of the record.
+///
+public class with_a_duplicate_subject_response : given.an_attested_invite_completion
+{
+ const string BackendResponseBody = "{\"conflictingUser\":\"sensitive-backend-detail\"}";
+
+ async Task Because()
+ {
+ _handler.StatusCode = HttpStatusCode.Conflict;
+ _handler.ResponseBody = BackendResponseBody;
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_record_the_collision() => _logger.Text.ShouldContain("already associated with an existing user");
+ [Fact] void should_not_disclose_the_backend_response_body() => _logger.Text.ShouldNotContain("sensitive-backend-detail");
+ [Fact] void should_not_disclose_the_provider_subject() => _logger.Text.ShouldNotContain("provider-subject");
+ [Fact] void should_not_claim_the_invitation_was_exchanged() => _logger.Text.ShouldNotContain("Invite exchanged successfully");
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_a_legacy_subject_and_a_duplicate_subject_response.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_a_legacy_subject_and_a_duplicate_subject_response.cs
index 7e06401..d4f0f36 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_a_legacy_subject_and_a_duplicate_subject_response.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_a_legacy_subject_and_a_duplicate_subject_response.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_logging_an_invitation;
///
-/// The exchange endpoint answering 409 says the subject already belongs to a user. That the collision
+/// The exchange endpoint answering 409 says the subject already belongs to a user. That the collision
/// happened is the diagnostic; the colliding identifier is not.
///
public class with_a_legacy_subject_and_a_duplicate_subject_response : given.an_invite_exchange_with_recorded_logs
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_an_invalid_capability.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_an_invalid_capability.cs
index 03a4b5f..ffc8697 100644
--- a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_an_invalid_capability.cs
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_logging_an_invitation/with_an_invalid_capability.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_logging_an_invitation;
///
-/// An invitation arrives as /invite/{capability}, so on a Phase-1 invitation request the request path
+/// An invitation arrives as /invite/{capability}, so on a Phase-1 invitation request the request path
/// is a live bearer capability. Refusing it must not be the thing that writes it to every log sink.
///
public class with_an_invalid_capability : given.an_invite_exchange_with_recorded_logs
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_cleared.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_cleared.cs
new file mode 100644
index 0000000..70742e8
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_cleared.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_the_configuration_reloads_during_completion;
+
+///
+/// The invited-email claim type is blanked while the completion is awaiting the session, and the identity
+/// provider supplies no address at all. A completion stage that re-read the configuration would treat the
+/// blank claim type as an invitation bound to nobody and attest an identity with no verified address on it.
+///
+public class and_the_email_claim_is_cleared : given.an_attested_invite_completion
+{
+ protected override bool IncludeVerifiedEmailClaims => false;
+ protected override string? ReloadedEmailClaimDuringSession => string.Empty;
+
+ async Task Because() => await _middleware.InvokeAsync(_context);
+
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_unavailable_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailUnavailable, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed.cs
new file mode 100644
index 0000000..610f4df
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed.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_InviteMiddleware.when_the_configuration_reloads_during_completion;
+
+///
+/// The invited-email claim type is renamed while the completion is awaiting the session, so the capability
+/// no longer carries a claim of the configured type at all. The person completing the invitation is its
+/// actual recipient, and stays it: the recipient was captured with the entry state before the reload, so the
+/// completion still binds to the address the invitation was issued for.
+///
+///
+/// This is the positive pole of this folder. A fix that simply refused everything after a reload would pass
+/// the refusal specifications here and fail this one.
+///
+public class and_the_email_claim_is_renamed : given.an_attested_invite_completion
+{
+ protected override string? ReloadedEmailClaimDuringSession => "renamed_invited_email";
+
+ async Task Because() => await _middleware.InvokeAsync(_context);
+
+ [Fact] void should_attest_the_captured_invited_recipient() => _attestationIssuer.Identity!.Email.ShouldEqual(Email);
+ [Fact] void should_attest_the_canonical_provider_subject() => _attestationIssuer.Identity!.ProviderSubject.ShouldEqual("provider-subject");
+ [Fact] void should_call_the_completion_endpoint() => _handler.Request.ShouldNotBeNull();
+ [Fact] void should_continue_only_after_success() => _nextCalled.ShouldBeTrue();
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed_for_another_account.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed_for_another_account.cs
new file mode 100644
index 0000000..8b4bc7c
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_email_claim_is_renamed_for_another_account.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_the_configuration_reloads_during_completion;
+
+///
+/// The invited-email claim type is renamed while the completion is awaiting the session, and the account
+/// answering the challenge is somebody else's. A completion stage that re-read the claim type at this point
+/// would find the capability carrying no invited address, read that as an invitation bound to nobody, and
+/// hand a signed attestation for the wrong person to the exchange endpoint.
+///
+public class and_the_email_claim_is_renamed_for_another_account : given.an_attested_invite_completion
+{
+ protected override string? ReloadedEmailClaimDuringSession => "renamed_invited_email";
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.RemoveClaim(identity.FindFirst("email"));
+ identity.AddClaim(new Claim("email", "someone-else@example.com"));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_invite_configuration_disappears.cs b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_invite_configuration_disappears.cs
new file mode 100644
index 0000000..669feda
--- /dev/null
+++ b/Source/AuthProxy.Specs/Invites/for_InviteMiddleware/when_the_configuration_reloads_during_completion/and_the_invite_configuration_disappears.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.AuthProxy.Invites.for_InviteMiddleware.when_the_configuration_reloads_during_completion;
+
+///
+/// The whole invite configuration is withdrawn while the completion is awaiting the session, and the
+/// identity provider flagged the account's address as unverified. Neither fact may produce an attestation:
+/// the invitation's recipient was captured before the reload, and unverified provider evidence never
+/// satisfies it.
+///
+public class and_the_invite_configuration_disappears : given.an_attested_invite_completion
+{
+ protected override bool RemovesInviteDuringSession => true;
+
+ async Task Because()
+ {
+ var identity = (ClaimsIdentity)_context.User.Identity!;
+ identity.RemoveClaim(identity.FindFirst("email_verified"));
+ identity.AddClaim(new Claim("email_verified", bool.FalseString.ToLowerInvariant()));
+
+ await _middleware.InvokeAsync(_context);
+ }
+
+ [Fact] void should_not_issue_a_complete_attestation() => _attestationIssuer.Identity.ShouldBeNull();
+ [Fact] void should_not_call_the_completion_endpoint() => _handler.Request.ShouldBeNull();
+ [Fact] void should_not_continue_the_pipeline() => _nextCalled.ShouldBeFalse();
+ [Fact] void should_serve_the_email_mismatch_page() => _errorPageProvider.Received(1).WriteErrorPageAsync(_context, WellKnownPageNames.InvitationEmailMismatch, StatusCodes.Status403Forbidden);
+}
diff --git a/Source/AuthProxy.Specs/Links/for_LinkCallbackCompletion/when_the_exchange_fails/and_the_return_url_is_hostile.cs b/Source/AuthProxy.Specs/Links/for_LinkCallbackCompletion/when_the_exchange_fails/and_the_return_url_is_hostile.cs
index 3ca98bb..d373dce 100644
--- a/Source/AuthProxy.Specs/Links/for_LinkCallbackCompletion/when_the_exchange_fails/and_the_return_url_is_hostile.cs
+++ b/Source/AuthProxy.Specs/Links/for_LinkCallbackCompletion/when_the_exchange_fails/and_the_return_url_is_hostile.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Links.for_LinkCallbackCompletion.when_the_exchange_fa
///
/// A tampered return URL must not reach the browser on the failure path either — neither as a
-/// Location header nor echoed into the page.
+/// Location header nor echoed into the page.
///
public class and_the_return_url_is_hostile : a_link_callback_context
{
diff --git a/Source/AuthProxy.Specs/Management/for_DataProtectionReadiness/given/a_readiness_check.cs b/Source/AuthProxy.Specs/Management/for_DataProtectionReadiness/given/a_readiness_check.cs
index e4c3f1b..f2721cb 100644
--- a/Source/AuthProxy.Specs/Management/for_DataProtectionReadiness/given/a_readiness_check.cs
+++ b/Source/AuthProxy.Specs/Management/for_DataProtectionReadiness/given/a_readiness_check.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Management.for_DataProtectionReadiness.given;
///
///
/// Deliberately the real stack rather than a substituted . What is
-/// being specified is that a Protect/Unprotect round-trip forces the key ring to initialize
+/// being specified is that a Protect/Unprotect round-trip forces the key ring to initialize
/// and reports what happened when it does — and a substitute would answer whatever it was told to,
/// including for the failure that matters.
///
diff --git a/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_only_ports_are_declared.cs b/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_only_ports_are_declared.cs
index a920eaf..1041b08 100644
--- a/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_only_ports_are_declared.cs
+++ b/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_only_ports_are_declared.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Management.for_ListenerAddresses;
///
/// The one that matters in production. The official .NET container images publish their port through
-/// ASPNETCORE_HTTP_PORTS and set no ASPNETCORE_URLS at all, so a resolution that read only
+/// ASPNETCORE_HTTP_PORTS and set no ASPNETCORE_URLS at all, so a resolution that read only
/// the URLs would conclude that a deployed AuthProxy listens on nothing — and re-declaring "nothing plus
/// the management listener" would take the whole proxy off the network.
///
diff --git a/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_urls_are_declared.cs b/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_urls_are_declared.cs
index 4b70f81..24169f4 100644
--- a/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_urls_are_declared.cs
+++ b/Source/AuthProxy.Specs/Management/for_ListenerAddresses/when_urls_are_declared.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Management.for_ListenerAddresses;
///
-/// ASPNETCORE_URLS is the highest-priority answer, and several of them are separated by semicolons.
+/// ASPNETCORE_URLS is the highest-priority answer, and several of them are separated by semicolons.
///
public class when_urls_are_declared : Specification
{
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementConfigurationValidator/when_a_path_is_not_rooted.cs b/Source/AuthProxy.Specs/Management/for_ManagementConfigurationValidator/when_a_path_is_not_rooted.cs
index 5b68611..eb07d98 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementConfigurationValidator/when_a_path_is_not_rooted.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementConfigurationValidator/when_a_path_is_not_rooted.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Management.for_ManagementConfigurationValidator;
///
-/// A path that does not start with / matches no request, so the listener would open, accept the
+/// A path that does not start with / matches no request, so the listener would open, accept the
/// probe's connection, and answer it the same not-found it answers everything else. That reads as an
/// application fault rather than as a typo in a path.
///
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/given/StatedReadiness.cs b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/given/StatedReadiness.cs
index 87f6754..4f3e2f0 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/given/StatedReadiness.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/given/StatedReadiness.cs
@@ -8,9 +8,9 @@ namespace Cratis.AuthProxy.Management.for_ManagementEndpoints.given;
///
/// What it answers.
///
-/// Hand-written rather than substituted. IReadinessCheck is internal, and a dynamic proxy over an
+/// Hand-written rather than substituted. IReadinessCheck is internal, and a dynamic proxy over an
/// internal type would need the proxy generator's assembly named in AuthProxy's own
-/// InternalsVisibleTo — a permanent widening of the shipped assembly's surface, to serve a spec.
+/// InternalsVisibleTo — a permanent widening of the shipped assembly's surface, to serve a spec.
///
/// Counting matters as much as answering: liveness must consult nothing, and the only way to see that it
/// did not is to ask this how many times it was called.
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_not_ready.cs b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_not_ready.cs
index 79b79fb..2e5fcba 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_not_ready.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_not_ready.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Management.for_ManagementEndpoints;
///
-/// An instance that cannot serve traffic answers 503 — and says no more about it than a ready one
+/// An instance that cannot serve traffic answers 503 — and says no more about it than a ready one
/// does.
///
/// The reason is the interesting part to an attacker and useless to a probe: it names a filesystem path, a
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_ready.cs b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_ready.cs
index ec4dbc8..80be6fc 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_ready.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementEndpoints/when_the_instance_is_ready.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Management.for_ManagementEndpoints;
///
-/// A ready instance answers 200, having asked the readiness check for the answer.
+/// A ready instance answers 200, having asked the readiness check for the answer.
///
public class when_the_instance_is_ready : given.a_management_endpoint
{
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementExtensions/when_a_management_section_is_declared.cs b/Source/AuthProxy.Specs/Management/for_ManagementExtensions/when_a_management_section_is_declared.cs
index b1d9d57..131ac04 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementExtensions/when_a_management_section_is_declared.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementExtensions/when_a_management_section_is_declared.cs
@@ -10,9 +10,9 @@ namespace Cratis.AuthProxy.Management.for_ManagementExtensions;
/// them.
///
/// This is the one assertion standing between an opt-in health endpoint and a total outage. Declaring the
-/// listener the idiomatic-looking way — ConfigureKestrel(options => options.Listen(...)) — makes
-/// Kestrel discard the hosting addresses entirely, because PreferHostingUrls is
-/// by default: it logs "Overriding address(es)" and binds only what Listen
+/// listener the idiomatic-looking way — ConfigureKestrel(options => options.Listen(...)) — makes
+/// Kestrel discard the hosting addresses entirely, because PreferHostingUrls is
+/// by default: it logs "Overriding address(es)" and binds only what Listen
/// named. The public listener
/// of every containerized deployment comes from those hosting addresses, so switching on a health endpoint
/// would take the proxy off the network and leave a perfectly healthy-looking probe answering on loopback.
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/given/an_isolated_management_listener.cs b/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/given/an_isolated_management_listener.cs
index ad8dc9c..e9321b0 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/given/an_isolated_management_listener.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/given/an_isolated_management_listener.cs
@@ -5,11 +5,11 @@ namespace Cratis.AuthProxy.Management.for_ManagementListenerIsolation.given;
///
/// A management listener on its own port, and the means to present it a request that arrived on a chosen
-/// socket carrying a chosen Host header.
+/// socket carrying a chosen Host header.
///
///
/// The two are set independently on purpose. Which socket a request arrived on is a fact of the connection;
-/// what the Host header says is whatever the caller wrote. Every question these specs ask is about
+/// what the Host header says is whatever the caller wrote. Every question these specs ask is about
/// what happens when the two disagree.
///
public class an_isolated_management_listener : Specification
diff --git a/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/when_the_host_header_names_the_management_port.cs b/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/when_the_host_header_names_the_management_port.cs
index 3afbc2c..e30fc0a 100644
--- a/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/when_the_host_header_names_the_management_port.cs
+++ b/Source/AuthProxy.Specs/Management/for_ManagementListenerIsolation/when_the_host_header_names_the_management_port.cs
@@ -4,12 +4,12 @@
namespace Cratis.AuthProxy.Management.for_ManagementListenerIsolation;
///
-/// The Host header decides nothing, in either direction.
+/// The Host header decides nothing, in either direction.
///
-/// ASP.NET's own way of scoping endpoints to a port is RequireHost("*:9110"), and it matches that
+/// ASP.NET's own way of scoping endpoints to a port is RequireHost("*:9110"), and it matches that
/// header — which is a string the caller typed. Written into a request on the public listener it would make
/// the private endpoints answer the internet; written into an ordinary request it would make the proxy
-/// refuse a legitimate caller who happens to sit behind an ingress that rewrites Host. Gating on the
+/// refuse a legitimate caller who happens to sit behind an ingress that rewrites Host. Gating on the
/// socket the request was accepted on is the only thing here a caller cannot reach.
///
///
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_service_declares_a_path_it_cannot_serve.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_service_declares_a_path_it_cannot_serve.cs
index 8d69c44..8747a25 100644
--- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_service_declares_a_path_it_cannot_serve.cs
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_a_service_declares_a_path_it_cannot_serve.cs
@@ -8,11 +8,11 @@ namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvid
///
/// A service with no endpoint to forward to must not take a prefix away from a service that has one.
///
-/// A Services entry does not have to declare a backend or a frontend — the lobby's registration
+/// A Services entry does not have to declare a backend or a frontend — the lobby's registration
/// service is configured that way — so a declaration on such an entry produces no route. If that
/// declaration still claimed the prefix, the service that can actually serve it would be skipped, and the
/// path would match no route at all while all three middlewares went on treating it as anonymous: a
-/// 404 on a path the deployment declared public, caused by a service that never even appears in the
+/// 404 on a path the deployment declared public, caused by a service that never even appears in the
/// route table.
///
///
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_declared_anonymous_paths_are_unusable.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_declared_anonymous_paths_are_unusable.cs
index 89864cc..a7f554b 100644
--- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_declared_anonymous_paths_are_unusable.cs
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_declared_anonymous_paths_are_unusable.cs
@@ -9,8 +9,8 @@ namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvid
/// An entry that is not a rooted path of literal segments must produce no route at all.
///
/// A declared prefix is interpolated into an ASP.NET route template, so an unvalidated entry would be
-/// read as template syntax rather than as a literal: /route{parameter} would match
-/// /routeanything/… anonymously, and //double or /catch/{**all} is not a legal
+/// read as template syntax rather than as a literal: /route{parameter} would match
+/// /routeanything/… anonymously, and //double or /catch/{**all} is not a legal
/// template at all and would fail the proxy's configuration load at startup. Neither can be reached from
/// configuration, and this pins that the route table sees exactly what the middlewares see.
///
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_service_declares_anonymous_paths.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_service_declares_anonymous_paths.cs
index 4b308f5..63d6395 100644
--- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_service_declares_anonymous_paths.cs
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_service_declares_anonymous_paths.cs
@@ -9,8 +9,8 @@ namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvid
/// A declared anonymous path must produce a route that relaxes the authorization policy, and nothing else
/// in the table may be relaxed with it.
///
-/// Every other generated route carries AuthorizationPolicy = "default", which is
-/// RequireAuthenticatedUser(). Clearing SelectProviderMiddleware and TenancyMiddleware
+/// Every other generated route carries AuthorizationPolicy = "default", which is
+/// RequireAuthenticatedUser(). Clearing SelectProviderMiddleware and TenancyMiddleware
/// gets a request as far as authorization and no further, so this is the third of the three places that
/// have to agree before a declared path is actually reachable.
///
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/given/a_provider_over_a_reloadable_configuration.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/given/a_provider_over_a_reloadable_configuration.cs
index 190c3b4..c28563b 100644
--- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/given/a_provider_over_a_reloadable_configuration.cs
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_the_configuration_reloads/given/a_provider_over_a_reloadable_configuration.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvider.when_the_configuration_reloads.given;
///
-/// A provider over a configuration declaring /portal anonymous, with the change listener it registers
+/// A provider over a configuration declaring /portal anonymous, with the change listener it registers
/// captured so a spec can call it the way a reload does. A single service with only a frontend keeps the
/// generated table small enough to read.
///
diff --git a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_two_services_declare_the_same_anonymous_path.cs b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_two_services_declare_the_same_anonymous_path.cs
index b258ade..c839f43 100644
--- a/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_two_services_declare_the_same_anonymous_path.cs
+++ b/Source/AuthProxy.Specs/ReverseProxy/for_MicroserviceReverseProxyConfigProvider/when_two_services_declare_the_same_anonymous_path.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.ReverseProxy.for_MicroserviceReverseProxyConfigProvid
/// A declared prefix is matched without any service-selection header or query parameter — an anonymous
/// caller cannot be expected to send one — so two services declaring the same prefix emit two routes with
/// an identical template and an identical order. ASP.NET cannot choose between them and throws
-/// AmbiguousMatchException, which surfaces as HTTP 500 on the declared path: a
+/// AmbiguousMatchException, which surfaces as HTTP 500 on the declared path: a
/// configuration mistake that is invisible at startup and takes down exactly the path it was meant to
/// open.
///
@@ -72,7 +72,7 @@ bool AnonymousRouteTemplatesAreUnique() =>
/// Every route whose template matches, ignoring case.
///
/// A declared prefix is not lower-cased on the way in, so an ordinal comparison would see
- /// /portal and /Portal as two different templates and miss the collision entirely —
+ /// /portal and /Portal as two different templates and miss the collision entirely —
/// while ASP.NET, matching case-insensitively, still cannot choose between them.
///
IEnumerable RoutesMatching(string path) =>
diff --git a/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/AuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/AuthProxyFactory.cs
index b8b7704..c925e2a 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/AuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/AuthProxyFactory.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Scenarios.when_a_bundled_web_asset_is_requested;
///
/// A proxy with a configured backend service — so its reverse-proxy route exists, the way it always does
-/// in a real deployment — and a bundled web asset under wwwroot, the way the login-selection SPA's
+/// in a real deployment — and a bundled web asset under wwwroot, the way the login-selection SPA's
/// build output is.
///
public class AuthProxyFactory : WebApplicationFactory
diff --git a/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/and_the_caller_is_anonymous.cs b/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/and_the_caller_is_anonymous.cs
index 5a1cb6a..cbabf9e 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/and_the_caller_is_anonymous.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_a_bundled_web_asset_is_requested/and_the_caller_is_anonymous.cs
@@ -4,13 +4,13 @@
namespace Cratis.AuthProxy.Scenarios.when_a_bundled_web_asset_is_requested;
///
-/// Regression coverage for a bundled wwwroot asset (e.g. the login-selection SPA's own script)
+/// Regression coverage for a bundled wwwroot asset (e.g. the login-selection SPA's own script)
/// being refused instead of served once the reverse proxy has a real backend configured. Without an
-/// explicit UseRouting() anchored after UseStaticFiles(), ASP.NET Core's implicit routing
+/// explicit UseRouting() anchored after UseStaticFiles(), ASP.NET Core's implicit routing
/// insertion matches the reverse proxy's catch-all route before static files run, and
-/// UseStaticFiles — being endpoint-aware — skips a request that already carries a matched endpoint.
-/// The request then falls through to SelectProviderMiddleware, which refuses every unauthenticated,
-/// non-navigating caller with a 401, so the asset never loads and the page it belongs to renders
+/// UseStaticFiles — being endpoint-aware — skips a request that already carries a matched endpoint.
+/// The request then falls through to SelectProviderMiddleware, which refuses every unauthenticated,
+/// non-navigating caller with a 401, so the asset never loads and the page it belongs to renders
/// blank.
///
/// The proxy under test, with a real backend route and a bundled asset.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/AuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/AuthProxyFactory.cs
index 0f93ed6..d95dea5 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/AuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/AuthProxyFactory.cs
@@ -46,7 +46,7 @@ protected override void Dispose(bool disposing)
///
/// Gets the tenant-resolution settings the host runs with.
- /// Defaults to a strategy that always resolves, so that TenancyMiddleware never reaches its
+ /// Defaults to a strategy that always resolves, so that TenancyMiddleware never reaches its
/// tenant-unresolved branch and the scenario measures the other enforcement points on their own.
///
protected virtual IEnumerable> TenantResolutionSettings =>
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/SingleServiceAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/SingleServiceAuthProxyFactory.cs
index 237b58e..96a8cb0 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/SingleServiceAuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/SingleServiceAuthProxyFactory.cs
@@ -11,13 +11,13 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
///
/// The other factories in this folder inherit a second service from the host's development settings, so
/// every generated route there is selected by a header or a query parameter and a declared prefix has no
-/// competition. With a single service the proxy also emits /{**catch-all} carrying the
+/// competition. With a single service the proxy also emits /{**catch-all} carrying the
/// authenticated-user policy, which overlaps every declared prefix — so this is the shape where the
/// declared route has to win on order, and the shape a single-application deployment actually runs.
///
///
/// Running outside the development environment is what removes that inherited service: the host's
-/// appsettings.Development.json is where it is declared.
+/// appsettings.Development.json is where it is declared.
///
///
public class SingleServiceAuthProxyFactory : AuthProxyFactory
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/TenantSelectionAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/TenantSelectionAuthProxyFactory.cs
index a0c151e..7bd79a5 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/TenantSelectionAuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/TenantSelectionAuthProxyFactory.cs
@@ -12,7 +12,7 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
///
/// The same host with tenant selection configured and a caller who is signed in but has not
/// chosen a tenant — the state a signed-in user is in on their first request, and the one state in which
-/// TenantSelectionMiddleware answers rather than forwards.
+/// TenantSelectionMiddleware answers rather than forwards.
///
public class TenantSelectionAuthProxyFactory : AuthProxyFactory
{
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/UnresolvedTenantAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/UnresolvedTenantAuthProxyFactory.cs
index 6ec50b9..ba25b7c 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/UnresolvedTenantAuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/UnresolvedTenantAuthProxyFactory.cs
@@ -5,11 +5,11 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
///
/// The same host, but with a tenant-resolution strategy that cannot resolve anything for a caller with no
-/// session — which is the only configuration in which TenancyMiddleware's refusal branch is reached
+/// session — which is the only configuration in which TenancyMiddleware's refusal branch is reached
/// at all.
///
/// This exists because the default factory resolves a fixed tenant for every request, so
-/// TenancyMiddleware never gets to refuse and the scenario would stay green with that enforcement
+/// TenancyMiddleware never gets to refuse and the scenario would stay green with that enforcement
/// point removed. An anonymous path only works if all three points agree, so each has to be able to fail
/// the suite on its own.
///
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_a_single_service_is_deployed.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_a_single_service_is_deployed.cs
index ed2e1c4..58118e6 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_a_single_service_is_deployed.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_a_single_service_is_deployed.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
/// End-to-end in a single-service deployment: the declared prefixes must beat the catch-all route that
/// shape adds.
///
-/// A single-service proxy also emits /{**catch-all} and /api/{**catch-all} carrying the
+/// A single-service proxy also emits /{**catch-all} and /api/{**catch-all} carrying the
/// authenticated-user policy, and both overlap every declared prefix. The declared route only wins by
/// being ordered ahead of them; if it lost, the request would be refused by authorization on the catch-all
/// instead — reachable in the deployment shape a single application actually runs, and invisible in a
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_the_tenant_cannot_be_resolved.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_the_tenant_cannot_be_resolved.cs
index ed82668..ac90844 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_the_tenant_cannot_be_resolved.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_the_tenant_cannot_be_resolved.cs
@@ -9,9 +9,9 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
/// End-to-end scenario with tenant resolutions configured that cannot resolve for a caller with no
/// session: the declared anonymous paths must still be forwarded rather than refused for having no tenant.
///
-/// The test destinations do not exist, so reaching the forwarder surfaces as 502 Bad Gateway — a
-/// status only a forwarded request can produce. Without TenancyMiddleware's anonymous skip these
-/// would be 401 instead: the same closed door as before the feature, one middleware later.
+/// The test destinations do not exist, so reaching the forwarder surfaces as 502 Bad Gateway — a
+/// status only a forwarded request can produce. Without TenancyMiddleware's anonymous skip these
+/// would be 401 instead: the same closed door as before the feature, one middleware later.
///
///
/// The shared application factory.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_two_services_declare_the_same_path.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_two_services_declare_the_same_path.cs
index c13c42b..aca1a37 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_two_services_declare_the_same_path.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_two_services_declare_the_same_path.cs
@@ -9,9 +9,9 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
/// End-to-end: two services declaring the same prefix must still serve it.
///
/// The declared routes carry no service-selection header or query match, so two declarations of the same
-/// prefix are two routes ASP.NET cannot choose between — AmbiguousMatchException, surfacing as
-/// 500 on the declared path. Nothing reports it at startup; the first anonymous caller finds it.
-/// A 502 here is the proof the request was forwarded to a real destination rather than dying in
+/// prefix are two routes ASP.NET cannot choose between — AmbiguousMatchException, surfacing as
+/// 500 on the declared path. Nothing reports it at startup; the first anonymous caller finds it.
+/// A 502 here is the proof the request was forwarded to a real destination rather than dying in
/// route selection.
///
///
diff --git a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_unauthenticated_user_requests_them.cs b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_unauthenticated_user_requests_them.cs
index a9bc43d..9a41574 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_unauthenticated_user_requests_them.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_anonymous_paths_are_configured/and_unauthenticated_user_requests_them.cs
@@ -12,18 +12,18 @@ namespace Cratis.AuthProxy.Scenarios.when_anonymous_paths_are_configured;
/// The declared paths must get all the way to the reverse proxy — past
/// and past the authorization policy on the
/// generated route. The test destinations do not exist, so reaching the forwarder surfaces as a
-/// 502 Bad Gateway; that is the assertion's whole point — it can only be produced by a request
+/// 502 Bad Gateway; that is the assertion's whole point — it can only be produced by a request
/// that was forwarded. An undeclared path must be unaffected and still receive the selection page, which
/// is what proves the prefixes do not over-match.
///
///
-/// The declared paths are requested without any fetch metadata or Accept header — the shape a
+/// The declared paths are requested without any fetch metadata or Accept header — the shape a
/// webhook or a bare client sends, and the shape that is otherwise refused outright. Getting them
/// forwarded is what pins the ordering: the declared-path skip has to be reached before the caller is
/// ever classified, or declaring a path anonymous would open it to browsers only.
///
///
-/// This factory resolves a fixed tenant for every request, so TenancyMiddleware's refusal branch is
+/// This factory resolves a fixed tenant for every request, so TenancyMiddleware's refusal branch is
/// never entered here — that third enforcement point is covered by
/// .
///
diff --git a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/ObservedAnswer.cs b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/ObservedAnswer.cs
index 912fd2b..401c0f0 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/ObservedAnswer.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/ObservedAnswer.cs
@@ -12,12 +12,12 @@ namespace Cratis.AuthProxy.Scenarios.when_capability_only_admission_is_configure
/// Every header that came back, normalized and sorted.
/// The bytes that came back.
///
-/// Comparing statuses alone is the mistake this type exists to prevent: a gate can answer 404
-/// everywhere and still describe the deployment through an Allow, a WWW-Authenticate, a
+/// Comparing statuses alone is the mistake this type exists to prevent: a gate can answer 404
+/// everywhere and still describe the deployment through an Allow, a WWW-Authenticate, a
/// content type or a body length that varies with the route.
///
-/// Date is excluded because it is a clock reading rather than anything about the request. Everything
-/// else — including Content-Length and Content-Type — is compared exactly.
+/// Date is excluded because it is a clock reading rather than anything about the request. Everything
+/// else — including Content-Length and Content-Type — is compared exactly.
///
///
public sealed record ObservedAnswer(string Route, string Method, int StatusCode, string Headers, string Body)
diff --git a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_no_service_declares_client_credentials.cs b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_no_service_declares_client_credentials.cs
index 720bdb3..185b86d 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_no_service_declares_client_credentials.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_no_service_declares_client_credentials.cs
@@ -16,7 +16,7 @@ namespace Cratis.AuthProxy.Scenarios.when_capability_only_admission_is_configure
/// have one.
///
///
-/// Asserted against the routing table rather than against a response, because a 404 is what the gate
+/// Asserted against the routing table rather than against a response, because a 404 is what the gate
/// answers to everything and would say nothing about whether the route was ever declared.
///
///
diff --git a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_nothing_is_presented.cs b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_nothing_is_presented.cs
index c12c8c3..f6c76e2 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_nothing_is_presented.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_capability_only_admission_is_configured/and_nothing_is_presented.cs
@@ -8,9 +8,9 @@ namespace Cratis.AuthProxy.Scenarios.when_capability_only_admission_is_configure
/// indistinguishable refusal.
///
/// This is the whole feature stated as one table. AuthProxy's interactive contract is otherwise an oracle
-/// in five different voices: /.cratis/login/{scheme} says whether a provider is configured,
-/// /.cratis/providers lists them, /.cratis/token confirms an AuthProxy is answering,
-/// SelectProviderMiddleware answers a browser and an API caller differently, and the invite
+/// in five different voices: /.cratis/login/{scheme} says whether a provider is configured,
+/// /.cratis/providers lists them, /.cratis/token confirms an AuthProxy is answering,
+/// SelectProviderMiddleware answers a browser and an API caller differently, and the invite
/// middleware distinguishes an expired invitation from an invalid one. A closed deployment has to answer
/// all of them the same way it answers a path that was never there.
///
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 499b200..13d5336 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
@@ -65,10 +65,10 @@ public CallbackAuthProxyFactory()
public HttpStatusCode ExchangeStatusCode { get; set; } = HttpStatusCode.OK;
///
- /// Extracts the reusable name=value cookie pairs a response set, skipping deletions.
+ /// Extracts the reusable name=value cookie pairs a response set, skipping deletions.
///
/// The response whose cookies to collect.
- /// The cookie pairs, ready for a Cookie request header.
+ /// The cookie pairs, ready for a Cookie request header.
public static IReadOnlyList CookiesFrom(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var setCookies)
? setCookies
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/OidcCallbackAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/OidcCallbackAuthProxyFactory.cs
new file mode 100644
index 0000000..3f58c09
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/OidcCallbackAuthProxyFactory.cs
@@ -0,0 +1,516 @@
+// 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;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Cratis.AuthProxy.Invites;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback;
+
+///
+/// WebApplicationFactory that drives the real OIDC authentication handler - state, correlation and nonce
+/// cookies, framework token validation, and the signed two-stage attested invitation protocol - against a
+/// faked identity provider back channel: discovery document, JWKS, token and userinfo endpoints. Unlike
+/// , which exercises the OAuth2 handler, this is the OIDC sibling the
+/// attested callback path had no coverage for.
+///
+///
+/// The provider handshake is driven for real: AuthProxy generates its own state, correlation cookie and
+/// nonce; the fake back channel signs an id_token carrying whatever nonce the challenge actually
+/// generated, and a real signature/issuer/audience/nonce/at_hash validation runs against it. Nothing
+/// fabricates the invitation capability binding a session carries - a session only carries it because the
+/// challenge that established it bound it, exactly as production does.
+///
+public class OidcCallbackAuthProxyFactory : WebApplicationFactory
+{
+ public const string ProviderName = "TestOidc";
+ public const string ProviderScheme = "testoidc";
+ public const string Authority = "https://idp.test";
+ public const string StageUrl = "https://exchange.test/invites/stage";
+ public const string ExchangeUrl = "https://exchange.test/invites/exchange";
+ public const string LobbyUrl = "http://lobby.test/";
+ public const string TenantId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
+ public const string TenantClaim = "tenant_id";
+ public const string CanonicalProviderKey = "testoidc";
+ public const string SessionCookieName = ".Cratis.AuthProxy.Auth.v2";
+ public const string DefaultSubject = "oidc-subject-1";
+ public const string DefaultEmail = "invitee@example.com";
+ public const string DefaultAssurance = "urn:mace:incommon:iap:silver";
+ public const string AttestationIssuer = "https://authproxy.test";
+ public const string AttestationAudience = "oidc-callback-spec";
+
+ const string AttestationKeyId = "oidc-callback-spec-key";
+
+ readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ readonly RSA _idpSigningKey = RSA.Create(2048);
+ readonly string _idpKeyId = Guid.NewGuid().ToString("N");
+ readonly HttpClient _idpBackchannel;
+ readonly RSA _attestationSigningKey = RSA.Create(2048);
+ readonly string _attestationPrivateKeyPem;
+ readonly RsaSecurityKey _attestationVerificationKey;
+ readonly Lock _calls = new();
+ readonly List _stageCalls = [];
+ readonly List _exchangeCalls = [];
+
+ ///
+ /// The nonce only ever appears in the authorize redirect's query string - never in the token request -
+ /// so it is captured there and handed to the fake token endpoint out of band, exactly as a real identity
+ /// provider would have remembered the nonce it was challenged with for this authorization code.
+ ///
+ string _pendingNonce = string.Empty;
+
+ /// Initializes a new instance of the class.
+ public OidcCallbackAuthProxyFactory()
+ {
+ Directory.CreateDirectory(_pagesPath);
+ File.WriteAllText(Path.Combine(_pagesPath, "invitation-expired.html"), "Invitation Expired
");
+ File.WriteAllText(Path.Combine(_pagesPath, "invitation-invalid.html"), "Invitation Invalid
");
+ File.WriteAllText(Path.Combine(_pagesPath, "invitation-select-provider.html"), "Select Provider
");
+ File.WriteAllText(Path.Combine(_pagesPath, "invitation-email-mismatch.html"), "Email Mismatch
");
+ File.WriteAllText(Path.Combine(_pagesPath, "invitation-email-unavailable.html"), "Email Unavailable
");
+
+ _attestationPrivateKeyPem = _attestationSigningKey.ExportPkcs8PrivateKeyPem();
+
+ // One long-lived verification key, built from exported parameters rather than a live RSA instance:
+ // Microsoft.IdentityModel caches signature providers per security key, so a per-call key wrapping a
+ // disposed RSA makes the second validation in a specification fail against a perfectly good signature.
+ _attestationVerificationKey = new RsaSecurityKey(_attestationSigningKey.ExportParameters(false)) { KeyId = AttestationKeyId };
+
+ // Owned by the factory, not by the options: nothing in the OIDC options pipeline disposes an
+ // externally supplied Backchannel, and the ConfigurationManager below keeps using it for the whole
+ // fixture lifetime.
+ _idpBackchannel = new HttpClient(new FakeIdentityProvider(this)) { Timeout = TimeSpan.FromSeconds(10) };
+ }
+
+ public (RsaSecurityKey PrivateKey, string PublicKeyPem) InviteKeyPair { get; } = TokenFixture.GenerateKeyPair();
+
+ public int ExchangeCallCount
+ {
+ get
+ {
+ lock (_calls)
+ {
+ return _exchangeCalls.Count;
+ }
+ }
+ }
+
+ /// Gets the calls the proxy made to the staging endpoint, in order.
+ public IReadOnlyList StageCalls
+ {
+ get
+ {
+ lock (_calls)
+ {
+ return [.. _stageCalls];
+ }
+ }
+ }
+
+ /// Gets the calls the proxy made to the completion exchange endpoint, in order.
+ public IReadOnlyList ExchangeCalls
+ {
+ get
+ {
+ lock (_calls)
+ {
+ return [.. _exchangeCalls];
+ }
+ }
+ }
+
+ /// Gets or sets the claims the fake identity provider's id_token and userinfo response carry, beyond sub/nonce/at_hash.
+ public IReadOnlyDictionary IdentityClaims { get; set; } = DefaultIdentityClaims();
+
+ /// Gets or sets the provider subject the fake identity provider asserts.
+ public string Subject { get; set; } = DefaultSubject;
+
+ ///
+ /// Gets or sets a value indicating whether the fake identity provider signs the id_token with a nonce
+ /// that does not match the one the challenge actually generated, for proving nonce validation is real.
+ ///
+ public bool SignWithWrongNonce { get; set; }
+
+ ///
+ /// Extracts the reusable name=value cookie pairs a response set, skipping deletions.
+ ///
+ /// The response whose cookies to collect.
+ /// The cookie pairs, ready for a Cookie request header.
+ public static IReadOnlyList CookiesFrom(HttpResponseMessage response) =>
+ CallbackAuthProxyFactory.CookiesFrom(response);
+
+ ///
+ /// Computes the base64url-encoded SHA-256 capability hash of an invitation, independently of the proxy.
+ ///
+ /// The invitation capability.
+ /// The capability hash the attestation is expected to carry.
+ public static string CapabilityHashOf(string capability) =>
+ Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(capability)));
+
+ ///
+ /// Restores every mutable knob to its default and forgets the calls recorded so far, so one specification
+ /// can never inherit another's identity provider behavior.
+ ///
+ public void Reset()
+ {
+ Subject = DefaultSubject;
+ IdentityClaims = DefaultIdentityClaims();
+ SignWithWrongNonce = false;
+ lock (_calls)
+ {
+ _stageCalls.Clear();
+ _exchangeCalls.Clear();
+ }
+ }
+
+ ///
+ /// Validates an attestation the way the receiving backend must: against the exact signing key, issuer,
+ /// audience, algorithm and lifetime the proxy is configured with, never by decoding it unverified.
+ ///
+ /// The attestation bearer token to validate.
+ /// The validated claims.
+ /// Thrown when the attestation does not validate.
+ public async Task> ValidateAttestation(string attestation)
+ {
+ var result = await new JsonWebTokenHandler().ValidateTokenAsync(attestation, new TokenValidationParameters
+ {
+ ValidIssuer = AttestationIssuer,
+ ValidAudience = AttestationAudience,
+ IssuerSigningKey = _attestationVerificationKey,
+ ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateIssuerSigningKey = true,
+ ValidateLifetime = true,
+ });
+
+ if (!result.IsValid)
+ {
+ throw new InvalidOperationException("The attestation did not validate against the configured key, issuer and audience.", result.Exception);
+ }
+
+ return result.ClaimsIdentity.Claims.ToDictionary(claim => claim.Type, claim => claim.Value, StringComparer.Ordinal);
+ }
+
+ ///
+ /// Creates an that does not follow redirects, so every hop of the flow can be
+ /// inspected and its cookies carried forward explicitly - the way a spec plays the browser.
+ ///
+ /// A configured .
+ public HttpClient CreateBrowser() =>
+ CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false });
+
+ ///
+ /// Starts the OIDC challenge at and answers it on the callback with a fake
+ /// identity provider round trip, exactly as the browser and a real OIDC provider would between them.
+ ///
+ /// The client playing the browser.
+ /// The path that starts the challenge (an invitation URL or a login endpoint).
+ /// An additional cookie pair the browser presents on the callback only.
+ ///
+ /// An optional transform applied to the challenge cookies before they are presented on the callback, so a
+ /// spec can tamper with one exact cookie (e.g. corrupt the protected invitation-entry state) while every
+ /// other real handshake cookie - correlation, nonce, state - still round-trips unmodified.
+ ///
+ /// The whole round trip: both responses and the cookies each one set.
+ public async Task SignInThroughProvider(
+ HttpClient browser,
+ string path,
+ string? extraCallbackCookie = null,
+ Func, IReadOnlyList>? transformCallbackCookies = null)
+ {
+ var challenge = await browser.GetAsync(path);
+ var challengeCookies = CookiesFrom(challenge);
+ var state = ExtractQueryParameter(challenge.Headers.Location, "state");
+ _pendingNonce = ExtractQueryParameter(challenge.Headers.Location, "nonce");
+ var code = $"test-code-{Guid.NewGuid():N}";
+
+ var callbackCookies = (transformCallbackCookies ?? (cookies => cookies))(challengeCookies).ToList();
+ if (extraCallbackCookie is not null)
+ {
+ callbackCookies.Add(extraCallbackCookie);
+ }
+
+ using var callbackRequest = new HttpRequestMessage(HttpMethod.Get, $"/signin-{ProviderScheme}?code={code}&state={state}");
+ if (callbackCookies.Count > 0)
+ {
+ callbackRequest.Headers.Add("Cookie", string.Join("; ", callbackCookies));
+ }
+
+ var callback = await browser.SendAsync(callbackRequest);
+ return new ProviderSignIn(challenge, callback, challengeCookies, CookiesFrom(callback));
+ }
+
+ ///
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // The Development appsettings ship a placeholder Microsoft provider; running as Production keeps
+ // the configured OIDC provider the invitation's only one, so the challenge goes straight to it.
+ builder.UseEnvironment("Production");
+
+ foreach (var (key, value) in new Dictionary
+ {
+ [$"{C.AuthProxy.SectionKey}:Invite:PublicKeyPem"] = InviteKeyPair.PublicKeyPem,
+ [$"{C.AuthProxy.SectionKey}:Invite:ExchangeUrl"] = ExchangeUrl,
+ [$"{C.AuthProxy.SectionKey}:Invite:StageUrl"] = StageUrl,
+ [$"{C.AuthProxy.SectionKey}:Invite:EmailClaim"] = "email",
+ [$"{C.AuthProxy.SectionKey}:Invite:TenantClaim"] = TenantClaim,
+ [$"{C.AuthProxy.SectionKey}:Invite:Lobby:Frontend:BaseUrl"] = LobbyUrl,
+ [$"{C.AuthProxy.SectionKey}:Invite:MatchingTenantInvitationDestination"] = nameof(C.InvitationCompletionDestination.Lobby),
+ [$"{C.AuthProxy.SectionKey}:Invite:AppendInvitationIdToQueryString"] = "true",
+ [$"{C.AuthProxy.SectionKey}:Invite:Attestation:Issuer"] = AttestationIssuer,
+ [$"{C.AuthProxy.SectionKey}:Invite:Attestation:Audience"] = AttestationAudience,
+ [$"{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.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified),
+ [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = TenantId,
+ [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath,
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = ProviderName,
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = Authority,
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "test-client",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientSecret"] = "test-secret",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:InvitationCompletionEnabled"] = bool.TrueString,
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:ProviderKey"] = CanonicalProviderKey,
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:SubjectClaimType"] = "sub",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:EmailClaimType"] = "email",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:EmailVerifiedClaimType"] = "email_verified",
+ [$"{C.Authentication.SectionKey}:OidcProviders:0:CanonicalIdentity:AssuranceClaimType"] = "acr",
+ })
+ {
+ builder.UseSetting(key, value);
+ }
+
+ builder.ConfigureTestServices(services =>
+ {
+ services.PostConfigure(ProviderScheme, options =>
+ {
+ // The framework's own OpenIdConnectPostConfigureOptions already built a ConfigurationManager
+ // bound to the real backchannel by the time this later PostConfigure runs, so the manager
+ // itself - not just the Backchannel property - has to be rebuilt against the fake identity
+ // provider for discovery/JWKS to resolve against it.
+ options.Backchannel = _idpBackchannel;
+ options.RequireHttpsMetadata = true;
+ options.ConfigurationManager = new ConfigurationManager(
+ $"{Authority}/.well-known/openid-configuration",
+ new OpenIdConnectConfigurationRetriever(),
+ new HttpDocumentRetriever(_idpBackchannel) { RequireHttps = true });
+ });
+
+ services.AddSingleton(new TestHttpClientFactory(async (request, cancellationToken) =>
+ {
+ var url = request.RequestUri?.ToString() ?? string.Empty;
+
+ if (url.StartsWith(StageUrl, StringComparison.OrdinalIgnoreCase))
+ {
+ await Record(_stageCalls, request, cancellationToken);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ if (url.StartsWith(ExchangeUrl, StringComparison.OrdinalIgnoreCase))
+ {
+ await Record(_exchangeCalls, request, cancellationToken);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }));
+ });
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ _idpBackchannel.Dispose();
+ _idpSigningKey.Dispose();
+ _attestationSigningKey.Dispose();
+ if (Directory.Exists(_pagesPath))
+ {
+ Directory.Delete(_pagesPath, recursive: true);
+ }
+ }
+ }
+
+ static string ExtractQueryParameter(Uri? location, string name)
+ {
+ foreach (var pair in (location?.Query ?? string.Empty).TrimStart('?').Split('&'))
+ {
+ var parts = pair.Split('=', 2);
+ if (parts.Length == 2 && string.Equals(Uri.UnescapeDataString(parts[0]), name, StringComparison.Ordinal))
+ {
+ return parts[1];
+ }
+ }
+
+ throw new InvalidOperationException($"No '{name}' parameter on the challenge redirect '{location}'");
+ }
+
+ static string Base64UrlEncode(byte[] bytes) => Base64UrlEncoder.Encode(bytes);
+
+ static Dictionary DefaultIdentityClaims() => new()
+ {
+ ["email"] = DefaultEmail,
+ ["email_verified"] = "true",
+ ["acr"] = DefaultAssurance,
+ };
+
+ ///
+ /// One call the proxy made to an invitation endpoint, as the receiving backend would have seen it.
+ ///
+ /// The attestation presented in the Authorization header.
+ /// The request body.
+ public sealed record AttestedCall(string Bearer, string Body);
+
+ /// One full provider round trip: the challenge, the callback, and the cookies each set.
+ /// The response that redirected the browser to the identity provider.
+ /// The response answering the provider callback.
+ /// The cookie pairs the challenge set.
+ /// The cookie pairs the callback set.
+ public sealed record ProviderSignIn(
+ HttpResponseMessage Challenge,
+ HttpResponseMessage Callback,
+ IReadOnlyList ChallengeCookies,
+ IReadOnlyList CallbackCookies);
+
+ sealed class FakeIdentityProvider(OidcCallbackAuthProxyFactory factory) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromResult(Respond(request));
+
+ HttpResponseMessage Respond(HttpRequestMessage request)
+ {
+ var url = request.RequestUri?.ToString() ?? string.Empty;
+
+ if (url.StartsWith($"{Authority}/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase))
+ {
+ return JsonResponse(new
+ {
+ issuer = Authority,
+ authorization_endpoint = $"{Authority}/authorize",
+ token_endpoint = $"{Authority}/token",
+ userinfo_endpoint = $"{Authority}/userinfo",
+ jwks_uri = $"{Authority}/jwks",
+ response_types_supported = new[] { "code" },
+ subject_types_supported = new[] { "public" },
+ id_token_signing_alg_values_supported = new[] { SecurityAlgorithms.RsaSha256 },
+ });
+ }
+
+ if (url.StartsWith($"{Authority}/jwks", StringComparison.OrdinalIgnoreCase))
+ {
+ var parameters = factory._idpSigningKey.ExportParameters(false);
+ return JsonResponse(new
+ {
+ keys = new[]
+ {
+ new
+ {
+ kty = "RSA",
+ use = "sig",
+ kid = factory._idpKeyId,
+ alg = SecurityAlgorithms.RsaSha256,
+ n = Base64UrlEncode(parameters.Modulus!),
+ e = Base64UrlEncode(parameters.Exponent!),
+ },
+ },
+ });
+ }
+
+ if (url.StartsWith($"{Authority}/token", StringComparison.OrdinalIgnoreCase))
+ {
+ var accessToken = $"test-access-token-{Guid.NewGuid():N}";
+ var idToken = factory.CreateIdToken(accessToken, factory.SignWithWrongNonce ? $"wrong-{factory._pendingNonce}" : factory._pendingNonce);
+ return JsonResponse(new
+ {
+ access_token = accessToken,
+ id_token = idToken,
+ token_type = "Bearer",
+ expires_in = 3600,
+ });
+ }
+
+ if (url.StartsWith($"{Authority}/userinfo", StringComparison.OrdinalIgnoreCase))
+ {
+ var claims = new Dictionary(factory.IdentityClaims) { ["sub"] = factory.Subject };
+ return JsonResponse(claims);
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+
+ static HttpResponseMessage JsonResponse(object payload) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
+ };
+ }
+
+ async Task Record(List calls, HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var call = new AttestedCall(
+ request.Headers.Authorization?.Parameter ?? string.Empty,
+ request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken));
+ lock (_calls)
+ {
+ calls.Add(call);
+ }
+ }
+
+ string CreateIdToken(string accessToken, string nonce)
+ {
+ var claims = new Dictionary(StringComparer.Ordinal);
+ foreach (var (type, value) in IdentityClaims)
+ {
+ claims[type] = value;
+ }
+
+ claims["sub"] = Subject;
+ claims["nonce"] = nonce;
+ claims["at_hash"] = ComputeAtHash(accessToken);
+
+ var handler = new JsonWebTokenHandler();
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = Authority,
+ Audience = "test-client",
+ Claims = claims,
+ Expires = DateTime.UtcNow.AddMinutes(10),
+ NotBefore = DateTime.UtcNow.AddMinutes(-1),
+ IssuedAt = DateTime.UtcNow,
+ SigningCredentials = new SigningCredentials(new RsaSecurityKey(_idpSigningKey) { KeyId = _idpKeyId }, SecurityAlgorithms.RsaSha256),
+ };
+ return handler.CreateToken(descriptor);
+ }
+
+ static string ComputeAtHash(string accessToken)
+ {
+ var hash = SHA256.HashData(Encoding.ASCII.GetBytes(accessToken));
+ var half = hash.AsSpan(0, hash.Length / 2).ToArray();
+ return Base64UrlEncode(half);
+ }
+
+ sealed class TestHttpClientFactory(Func> handler) : IHttpClientFactory
+ {
+ public HttpClient CreateClient(string name) =>
+ new(new DispatchingHandler(handler)) { Timeout = TimeSpan.FromSeconds(10) };
+
+ sealed class DispatchingHandler(Func> handler) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ handler(request, cancellationToken);
+ }
+ }
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_capability_does_not_survive_the_round_trip.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_capability_does_not_survive_the_round_trip.cs
new file mode 100644
index 0000000..5dfbf85
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_capability_does_not_survive_the_round_trip.cs
@@ -0,0 +1,69 @@
+// 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 OIDC scenario: a real discovery/JWKS/token/userinfo round trip - AuthProxy's own state,
+/// correlation and nonce validated for real - answers a challenge that carries no invitation binding at
+/// all. This is the OIDC sibling of
+/// , which only exercises the OAuth2
+/// handler: nothing is exchanged on the callback, and no downstream completion call is ever made for a
+/// callback that does not answer this invitation's own challenge.
+///
+/// The shared OIDC application factory.
+public class and_the_oidc_capability_does_not_survive_the_round_trip(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ string _token;
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ int _exchangeCallsDuringCallback;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+
+ _token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims: [new Claim("jti", Guid.NewGuid().ToString())]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+
+ // The challenge starts from the plain login endpoint, so its state carries no invitation binding;
+ // the pending invitation cookie appears on the callback only - opened in another tab meanwhile.
+ _signIn = await factory.SignInThroughProvider(
+ browser,
+ $"/.cratis/login/{OidcCallbackAuthProxyFactory.ProviderScheme}?returnUrl=/",
+ extraCallbackCookie: $"{Cookies.InviteToken}={_token}");
+ _exchangeCallsDuringCallback = factory.ExchangeCallCount - exchangeCallsBefore;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public void should_challenge_the_oidc_provider() =>
+ Assert.StartsWith($"{OidcCallbackAuthProxyFactory.Authority}/authorize", _signIn.Challenge.Headers.Location?.ToString());
+
+ [Fact]
+ public void should_redirect_the_callback_to_its_own_return_url() =>
+ Assert.Equal("/", _signIn.Callback.Headers.Location?.ToString());
+
+ [Fact]
+ public void should_leave_the_pending_invitation_in_place_on_the_callback()
+ {
+ _signIn.Callback.Headers.TryGetValues("Set-Cookie", out var cookies);
+ Assert.DoesNotContain(
+ cookies ?? [],
+ cookie => cookie.StartsWith($"{Cookies.InviteToken}=;", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void should_not_call_the_completion_endpoint_on_the_callback() =>
+ Assert.Equal(0, _exchangeCallsDuringCallback);
+
+ [Fact]
+ public void should_establish_the_session_regardless() =>
+ Assert.Contains(_signIn.CallbackCookies, cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_entry_state_is_corrupted.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_entry_state_is_corrupted.cs
new file mode 100644
index 0000000..4c47b93
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_entry_state_is_corrupted.cs
@@ -0,0 +1,89 @@
+// 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 OIDC scenario: a real discovery/JWKS/token/userinfo round trip answers this invitation's own
+/// challenge - the capability binding survives - but the protected invitation-entry state cookie the browser
+/// presents on the callback has been corrupted between staging and the callback (a tampered cookie, or one
+/// that never round-tripped intact). The pre-HTTP entry-state guard fails closed with a bounded internal
+/// reason and the generic invalid-link page; no attestation is issued and no downstream completion call is
+/// ever made, exactly as issue #118 requires for a pre-HTTP failure.
+///
+/// The shared OIDC application factory.
+///
+/// This is the opposite pole of the email-outcome scenarios in this folder: same 403, different page. The
+/// body is asserted in both directions here too, so a regression that answered every refusal with one page
+/// cannot stay green on either side.
+///
+public class and_the_oidc_entry_state_is_corrupted(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ string _invitationId;
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ string _callbackBody;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+
+ _invitationId = Guid.NewGuid().ToString();
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", _invitationId),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", "invitee@example.com"),
+ ]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+ _signIn = await factory.SignInThroughProvider(
+ browser,
+ $"/invite/{token}",
+ transformCallbackCookies: cookies => cookies
+ .Select(cookie => cookie.StartsWith($"{Cookies.InvitationEntryState}=", StringComparison.Ordinal)
+ ? $"{Cookies.InvitationEntryState}=corrupted-not-a-real-protected-payload"
+ : cookie)
+ .ToArray());
+ _callbackBody = await _signIn.Callback.Content.ReadAsStringAsync();
+ _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_not_call_the_completion_endpoint() =>
+ Assert.Equal(0, _exchangeCallsDuringFlow);
+
+ [Fact]
+ public void should_refuse_the_completion() =>
+ Assert.Equal(System.Net.HttpStatusCode.Forbidden, _signIn.Callback.StatusCode);
+
+ [Fact]
+ public void should_answer_with_the_generic_invalid_link_page() =>
+ Assert.Contains("Invitation Invalid", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_an_email_outcome_page()
+ {
+ Assert.DoesNotContain("Email Mismatch", _callbackBody, StringComparison.Ordinal);
+ Assert.DoesNotContain("Email Unavailable", _callbackBody, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void should_not_redirect_to_lobby() =>
+ Assert.Null(_signIn.Callback.Headers.Location);
+
+ [Fact]
+ public void should_still_authenticate_the_session() =>
+ Assert.Contains(_signIn.CallbackCookies, cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_id_token_nonce_is_wrong.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_id_token_nonce_is_wrong.cs
new file mode 100644
index 0000000..006608e
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_id_token_nonce_is_wrong.cs
@@ -0,0 +1,47 @@
+// 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 OIDC scenario: the identity provider's id_token carries a nonce that does not match
+/// the one the challenge actually generated - an id_token replayed from, or forged for, a different
+/// handshake. The framework's own nonce validation fails this closed before any invitation completion code
+/// runs: no attestation is issued and the completion endpoint is never called. This proves the fixture's
+/// nonce handling is real validation, not a fixture that merely echoes whatever nonce it is given.
+///
+/// The shared OIDC application factory.
+public class and_the_oidc_id_token_nonce_is_wrong(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+ factory.SignWithWrongNonce = true;
+
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.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] void should_not_call_the_completion_endpoint() => Assert.Equal(0, _exchangeCallsDuringFlow);
+ [Fact] void should_fail_the_remote_round_trip_itself() => Assert.Contains("reason=remote-failure", _signIn.Callback.Headers.Location?.ToString());
+ [Fact] void should_not_redirect_to_lobby() => Assert.False(_signIn.Callback.Headers.Location?.ToString().StartsWith(OidcCallbackAuthProxyFactory.LobbyUrl, StringComparison.Ordinal) ?? false);
+ [Fact] void should_not_establish_a_session() => Assert.DoesNotContain(_signIn.CallbackCookies, cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_completes_with_a_verified_email.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_completes_with_a_verified_email.cs
new file mode 100644
index 0000000..2b97dc9
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_completes_with_a_verified_email.cs
@@ -0,0 +1,96 @@
+// 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 OIDC scenario: a real discovery/JWKS/token/userinfo round trip - AuthProxy's own state,
+/// correlation, nonce and canonical-identity validation all run for real - completes the signed two-stage
+/// attested invitation protocol on the callback itself. This is the OIDC sibling of
+/// , which only exercises the OAuth2 handler, and
+/// proves issue #118's reason propagation reaches a genuine success outcome over OIDC, not only the
+/// pre-identity guards.
+///
+/// The shared OIDC application factory.
+///
+/// The completion is inspected the way the receiving backend would: the attestation is validated against the
+/// configured key, issuer, audience, algorithm and lifetime - never merely decoded - and every fact on it is
+/// compared to an independently known value. The transaction and challenge are compared to the ones the
+/// staging call carried before the provider round trip started, so an attestation that agreed only with
+/// itself could not pass.
+///
+public class and_the_oidc_provider_completes_with_a_verified_email(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ string _invitationId;
+ string _capabilityHash;
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ IReadOnlyDictionary _stagedFacts;
+ IReadOnlyDictionary _attestedFacts;
+ string _exchangeBody;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+
+ _invitationId = Guid.NewGuid().ToString();
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", _invitationId),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", OidcCallbackAuthProxyFactory.DefaultEmail),
+ ]);
+ _capabilityHash = OidcCallbackAuthProxyFactory.CapabilityHashOf(token);
+
+ using var browser = factory.CreateBrowser();
+ _signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}");
+
+ _stagedFacts = await factory.ValidateAttestation(factory.StageCalls.Single().Bearer);
+ var exchange = factory.ExchangeCalls.Single();
+ _attestedFacts = await factory.ValidateAttestation(exchange.Bearer);
+ _exchangeBody = exchange.Body;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact] void should_challenge_the_oidc_provider() => Assert.StartsWith($"{OidcCallbackAuthProxyFactory.Authority}/authorize", _signIn.Challenge.Headers.Location?.ToString());
+ [Fact] void should_call_the_attested_completion_endpoint_exactly_once() => Assert.Equal(1, factory.ExchangeCallCount);
+ [Fact] void should_redirect_the_callback_to_the_lobby() => Assert.Equal($"{OidcCallbackAuthProxyFactory.LobbyUrl}?invitationId={_invitationId}", _signIn.Callback.Headers.Location?.ToString());
+ [Fact] void should_establish_the_session() => Assert.Contains(_signIn.CallbackCookies, cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+
+ [Fact] void should_stage_before_it_completes() => Assert.Equal(InvitationAttestationClaims.StagePurpose, _stagedFacts[InvitationAttestationClaims.Purpose]);
+ [Fact] void should_attest_a_completion() => Assert.Equal(InvitationAttestationClaims.CompletePurpose, _attestedFacts[InvitationAttestationClaims.Purpose]);
+ [Fact] void should_attest_the_invited_tenant() => Assert.Equal(OidcCallbackAuthProxyFactory.TenantId, _attestedFacts[InvitationAttestationClaims.TenantId]);
+ [Fact] void should_attest_the_invitation_it_was_issued_for() => Assert.Equal(_invitationId, _attestedFacts[InvitationAttestationClaims.InvitationId]);
+ [Fact] void should_attest_the_exact_capability_presented() => Assert.Equal(_capabilityHash, _attestedFacts[InvitationAttestationClaims.CapabilityHash]);
+ [Fact] void should_attest_the_staged_transaction() => Assert.Equal(_stagedFacts[InvitationAttestationClaims.InvitationTransaction], _attestedFacts[InvitationAttestationClaims.InvitationTransaction]);
+ [Fact] void should_attest_the_staged_challenge() => Assert.Equal(_stagedFacts[InvitationAttestationClaims.InvitationChallenge], _attestedFacts[InvitationAttestationClaims.InvitationChallenge]);
+
+ [Fact] void should_attest_the_configured_provider_key() => Assert.Equal(OidcCallbackAuthProxyFactory.CanonicalProviderKey, _attestedFacts[InvitationAttestationClaims.ProviderKey]);
+ [Fact] void should_attest_the_framework_validated_issuer() => Assert.Equal(OidcCallbackAuthProxyFactory.Authority, _attestedFacts[InvitationAttestationClaims.ProviderIssuer]);
+ [Fact] void should_attest_the_provider_subject() => Assert.Equal(OidcCallbackAuthProxyFactory.DefaultSubject, _attestedFacts[InvitationAttestationClaims.ProviderSubject]);
+ [Fact] void should_attest_the_verified_provider_email() => Assert.Equal(OidcCallbackAuthProxyFactory.DefaultEmail, _attestedFacts[InvitationAttestationClaims.Email]);
+ [Fact] void should_attest_that_the_email_was_verified() => Assert.Equal("True", _attestedFacts[InvitationAttestationClaims.EmailVerified], StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// The assurance is AuthProxy's own statement about the protocol that authenticated this session, not an
+ /// echo of whatever the provider put in its assurance claim.
+ ///
+ [Fact]
+ public void should_attest_the_protocol_assurance_authproxy_derived()
+ {
+ Assert.Equal("oidc", _attestedFacts[InvitationAttestationClaims.Assurance]);
+ Assert.NotEqual(OidcCallbackAuthProxyFactory.DefaultAssurance, _attestedFacts[InvitationAttestationClaims.Assurance]);
+ }
+
+ ///
+ /// The body may name the transaction and nothing else - the identity facts are the attestation's to
+ /// author, and a body that could name them would be a second, unsigned authority over the same question.
+ ///
+ [Fact]
+ public void should_send_only_the_staged_transaction_in_the_body() =>
+ Assert.Equal($"{{\"invitationTransaction\":\"{_stagedFacts[InvitationAttestationClaims.InvitationTransaction]}\"}}", _exchangeBody);
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_a_different_verified_email.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_a_different_verified_email.cs
new file mode 100644
index 0000000..2b8fcee
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_a_different_verified_email.cs
@@ -0,0 +1,78 @@
+// 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;
+using Cratis.AuthProxy.Invites;
+
+namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback;
+
+///
+/// End-to-end OIDC scenario: a real discovery/JWKS/token/userinfo round trip completes canonical identity
+/// resolution for real, and the identity provider supplies a verified email that is simply not the one the
+/// invitation was issued for. This is a real, actionable mismatch - account and address both genuine, just
+/// the wrong pairing - and is answered with the dedicated email-mismatch outcome.
+///
+/// The shared OIDC application factory.
+///
+/// The status code alone cannot show this: every invitation refusal answers 403. The page body is the only
+/// observable that tells the three outcomes apart, so it is asserted in both directions - the specific page
+/// is present, and the generic invalid-link page it used to collapse into is not.
+///
+public class and_the_oidc_provider_supplies_a_different_verified_email(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ string _callbackBody;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+ factory.IdentityClaims = new Dictionary
+ {
+ ["email"] = "someone-else@example.com",
+ ["email_verified"] = "true",
+ ["acr"] = OidcCallbackAuthProxyFactory.DefaultAssurance,
+ };
+
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", "invitee@example.com"),
+ ]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+ _signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}");
+ _callbackBody = await _signIn.Callback.Content.ReadAsStringAsync();
+ _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public void should_not_call_the_completion_endpoint() =>
+ Assert.Equal(0, _exchangeCallsDuringFlow);
+
+ [Fact]
+ public void should_refuse_the_completion() =>
+ Assert.Equal(HttpStatusCode.Forbidden, _signIn.Callback.StatusCode);
+
+ [Fact]
+ public void should_answer_with_the_email_mismatch_page() =>
+ Assert.Contains("Email Mismatch", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_generic_invalid_link_page() =>
+ Assert.DoesNotContain("Invitation Invalid", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_email_unavailable_page() =>
+ Assert.DoesNotContain("Email Unavailable", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_redirect_to_lobby() =>
+ Assert.Null(_signIn.Callback.Headers.Location);
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_an_unverified_email.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_an_unverified_email.cs
new file mode 100644
index 0000000..187916c
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_an_unverified_email.cs
@@ -0,0 +1,78 @@
+// 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;
+using Cratis.AuthProxy.Invites;
+
+namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback;
+
+///
+/// End-to-end OIDC scenario: a real discovery/JWKS/token/userinfo round trip completes canonical identity
+/// resolution for real, but the identity provider's email_verified claim is explicitly false. This
+/// is answered with the dedicated email-mismatch outcome, never a generic denial and never a silent
+/// success.
+///
+/// The shared OIDC application factory.
+///
+/// The status code alone cannot show this: every invitation refusal answers 403. The page body is the only
+/// observable that tells the three outcomes apart, so it is asserted in both directions - the specific page
+/// is present, and the generic invalid-link page it used to collapse into is not.
+///
+public class and_the_oidc_provider_supplies_an_unverified_email(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ string _callbackBody;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+ factory.IdentityClaims = new Dictionary
+ {
+ ["email"] = OidcCallbackAuthProxyFactory.DefaultEmail,
+ ["email_verified"] = "false",
+ ["acr"] = OidcCallbackAuthProxyFactory.DefaultAssurance,
+ };
+
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", "invitee@example.com"),
+ ]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+ _signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}");
+ _callbackBody = await _signIn.Callback.Content.ReadAsStringAsync();
+ _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public void should_not_call_the_completion_endpoint() =>
+ Assert.Equal(0, _exchangeCallsDuringFlow);
+
+ [Fact]
+ public void should_refuse_the_completion() =>
+ Assert.Equal(HttpStatusCode.Forbidden, _signIn.Callback.StatusCode);
+
+ [Fact]
+ public void should_answer_with_the_email_mismatch_page() =>
+ Assert.Contains("Email Mismatch", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_generic_invalid_link_page() =>
+ Assert.DoesNotContain("Invitation Invalid", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_email_unavailable_page() =>
+ Assert.DoesNotContain("Email Unavailable", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_redirect_to_lobby() =>
+ Assert.Null(_signIn.Callback.Headers.Location);
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_no_email.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_no_email.cs
new file mode 100644
index 0000000..a68da38
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_provider_supplies_no_email.cs
@@ -0,0 +1,80 @@
+// 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;
+using Cratis.AuthProxy.Invites;
+
+namespace Cratis.AuthProxy.Scenarios.when_invitation_completes_on_the_callback;
+
+///
+/// End-to-end OIDC scenario: a real discovery/JWKS/token/userinfo round trip completes canonical identity
+/// resolution for real, but the identity provider supplies no email claim at all. Issue #118 requires
+/// this to reach the dedicated unavailable-address outcome rather than collapsing into the generic
+/// invalid-link denial - proven here over a genuine OIDC handshake, not only a fabricated principal.
+///
+/// The shared OIDC application factory.
+///
+/// The status code alone cannot show this: every invitation refusal answers 403. The page body is the only
+/// observable that tells the three outcomes apart, so it is asserted in both directions - the specific page
+/// is present, and the generic invalid-link page it used to collapse into is not.
+///
+public class and_the_oidc_provider_supplies_no_email(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ OidcCallbackAuthProxyFactory.ProviderSignIn _signIn;
+ string _callbackBody;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+ factory.IdentityClaims = new Dictionary
+ {
+ ["acr"] = OidcCallbackAuthProxyFactory.DefaultAssurance,
+ };
+
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", "invitee@example.com"),
+ ]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+ _signIn = await factory.SignInThroughProvider(browser, $"/invite/{token}");
+ _callbackBody = await _signIn.Callback.Content.ReadAsStringAsync();
+ _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public void should_not_call_the_completion_endpoint() =>
+ Assert.Equal(0, _exchangeCallsDuringFlow);
+
+ [Fact]
+ public void should_refuse_the_completion() =>
+ Assert.Equal(HttpStatusCode.Forbidden, _signIn.Callback.StatusCode);
+
+ [Fact]
+ public void should_answer_with_the_email_unavailable_page() =>
+ Assert.Contains("Email Unavailable", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_generic_invalid_link_page() =>
+ Assert.DoesNotContain("Invitation Invalid", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_answer_with_the_email_mismatch_page() =>
+ Assert.DoesNotContain("Email Mismatch", _callbackBody, StringComparison.Ordinal);
+
+ [Fact]
+ public void should_not_redirect_to_lobby() =>
+ Assert.Null(_signIn.Callback.Headers.Location);
+
+ [Fact]
+ public void should_still_authenticate_the_session() =>
+ Assert.Contains(_signIn.CallbackCookies, cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_state_is_tampered.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_state_is_tampered.cs
new file mode 100644
index 0000000..13bf3ba
--- /dev/null
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_completes_on_the_callback/and_the_oidc_state_is_tampered.cs
@@ -0,0 +1,55 @@
+// 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 OIDC scenario: the callback presents a state value the challenge never issued. The
+/// framework's own correlation validation - not any AuthProxy invitation guard - fails this closed before
+/// any invitation completion code runs at all: no attestation is issued and the completion endpoint is
+/// never called.
+///
+/// The shared OIDC application factory.
+public class and_the_oidc_state_is_tampered(OidcCallbackAuthProxyFactory factory) : IClassFixture, IAsyncLifetime
+{
+ HttpResponseMessage _callback;
+ int _exchangeCallsDuringFlow;
+
+ public async Task InitializeAsync()
+ {
+ factory.Reset();
+
+ var token = TokenFixture.CreateToken(
+ factory.InviteKeyPair.PrivateKey,
+ additionalClaims:
+ [
+ new Claim("jti", Guid.NewGuid().ToString()),
+ new Claim(OidcCallbackAuthProxyFactory.TenantClaim, OidcCallbackAuthProxyFactory.TenantId),
+ new Claim("email", "invitee@example.com"),
+ ]);
+
+ using var browser = factory.CreateBrowser();
+ var exchangeCallsBefore = factory.ExchangeCallCount;
+
+ var challenge = await browser.GetAsync($"/invite/{token}");
+ var challengeCookies = OidcCallbackAuthProxyFactory.CookiesFrom(challenge);
+
+ using var callbackRequest = new HttpRequestMessage(HttpMethod.Get, $"/signin-{OidcCallbackAuthProxyFactory.ProviderScheme}?code=test-code&state=not-the-issued-state");
+ if (challengeCookies.Count > 0)
+ {
+ callbackRequest.Headers.Add("Cookie", string.Join("; ", challengeCookies));
+ }
+
+ _callback = await browser.SendAsync(callbackRequest);
+ _exchangeCallsDuringFlow = factory.ExchangeCallCount - exchangeCallsBefore;
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact] void should_not_call_the_completion_endpoint() => Assert.Equal(0, _exchangeCallsDuringFlow);
+ [Fact] void should_fail_the_remote_round_trip_itself() => Assert.Contains("reason=remote-failure", _callback.Headers.Location?.ToString());
+ [Fact] void should_not_redirect_to_lobby() => Assert.False(_callback.Headers.Location?.ToString().StartsWith(OidcCallbackAuthProxyFactory.LobbyUrl, StringComparison.Ordinal) ?? false);
+ [Fact] void should_not_establish_a_session() => Assert.DoesNotContain(OidcCallbackAuthProxyFactory.CookiesFrom(_callback), cookie => cookie.StartsWith(OidcCallbackAuthProxyFactory.SessionCookieName, StringComparison.Ordinal));
+}
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/AuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/AuthProxyFactory.cs
index 7904c24..4b6b3f8 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/AuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/AuthProxyFactory.cs
@@ -129,7 +129,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
///
/// Creates an that:
/// - Does not follow redirects (so tests can inspect 302 responses).
- /// - Optionally appears authenticated (sets the X-Test-Auth header on every request).
+ /// - Optionally appears authenticated (sets the X-Test-Auth header on every request).
/// - Optionally carries an invite token cookie.
///
/// Whether the client should appear authenticated.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/ClaimsForwardingAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/ClaimsForwardingAuthProxyFactory.cs
index e73ed40..d33759b 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/ClaimsForwardingAuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/ClaimsForwardingAuthProxyFactory.cs
@@ -4,8 +4,8 @@
namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
-/// Factory variant that adds ClaimsToForward configuration so that the
-/// organization_id claim from the invite token is forwarded as organization
+/// Factory variant that adds ClaimsToForward configuration so that the
+/// organization_id claim from the invite token is forwarded as organization
/// to the identity details provider.
///
public class ClaimsForwardingAuthProxyFactory : AuthProxyFactory
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/FrontendRoutedAuthProxyFactory.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/FrontendRoutedAuthProxyFactory.cs
index 872a25f..3cfb498 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/FrontendRoutedAuthProxyFactory.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/FrontendRoutedAuthProxyFactory.cs
@@ -13,10 +13,10 @@ namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
///
/// Every other scenario here declares a backend and nothing else, and a backend-only service is routed at
-/// /api/{**catch-all} alone — so an invitation path matches no route, is selected onto no endpoint,
+/// /api/{**catch-all} alone — so an invitation path matches no route, is selected onto no endpoint,
/// and reaches the invite middleware whatever the authorization step would have done with it. A real
-/// deployment declares a frontend as well (Studio sets Services:{key}:Frontend:BaseUrl alongside the
-/// backend), and a frontend is routed at /{**catch-all} — which matches every path there is,
+/// deployment declares a frontend as well (Studio sets Services:{key}:Frontend:BaseUrl alongside the
+/// backend), and a frontend is routed at /{**catch-all} — which matches every path there is,
/// including the two the proxy answers itself.
///
/// That single configuration difference is why an invitation link looped in production while every
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_a_frontend_route_covers_every_path.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_a_frontend_route_covers_every_path.cs
index da426d0..ddb3ad1 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_a_frontend_route_covers_every_path.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_a_frontend_route_covers_every_path.cs
@@ -11,8 +11,8 @@ namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
/// the invitation flow.
///
///
-/// The catch-all is generated with the default authorization policy — RequireAuthenticatedUser — and
-/// the invite middleware is registered after UseAuthorization. Without releasing the proxy-owned
+/// The catch-all is generated with the default authorization policy — RequireAuthenticatedUser — and
+/// the invite middleware is registered after UseAuthorization. Without releasing the proxy-owned
/// flows from the route that matched them, authorization refused the request first and redirected the
/// browser to provider selection: no invitation staged, and no pending-invitation cookie, so the sign-in
/// that followed carried no capability binding and the invitation could only complete on a later pass.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_concurrent_requests_arrive_without_identity_cookie.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_concurrent_requests_arrive_without_identity_cookie.cs
index 1f4d5b1..d31474b 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_concurrent_requests_arrive_without_identity_cookie.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_concurrent_requests_arrive_without_identity_cookie.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
/// End-to-end scenario: multiple simultaneous authenticated requests that all arrive without the
-/// .cratis-identity cookie must only trigger a single call to the identity details provider.
+/// .cratis-identity cookie must only trigger a single call to the identity details provider.
/// Without caching, every concurrent request would invoke the endpoint, causing a thundering-herd
/// effect on the initial page load after the invite exchange.
///
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs
index f0cf503..546e23c 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_forged_token_is_presented_at_exchange.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
/// End-to-end scenario for the Phase-2 trust boundary: an authenticated caller places a self-crafted,
-/// untrusted-signed token in the .cratis-invite cookie (which HTTP-only does not prevent).
+/// untrusted-signed token in the .cratis-invite cookie (which HTTP-only does not prevent).
/// AuthProxy must re-validate the token at the exchange forward and refuse to hand it to the exchange endpoint.
///
/// The shared application factory.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_identity_cookie_prevents_repeated_endpoint_calls.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_identity_cookie_prevents_repeated_endpoint_calls.cs
index e4a0f26..d433723 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_identity_cookie_prevents_repeated_endpoint_calls.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_identity_cookie_prevents_repeated_endpoint_calls.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
-/// End-to-end scenario: once a .cratis-identity cookie is present the identity details
+/// End-to-end scenario: once a .cratis-identity cookie is present the identity details
/// provider must not be called again on subsequent requests.
///
/// The shared application factory.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_invite_claims_are_forwarded_to_identity_provider.cs b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_invite_claims_are_forwarded_to_identity_provider.cs
index 8b7f294..f632d16 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_invite_claims_are_forwarded_to_identity_provider.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_invitation_link_is_used/and_invite_claims_are_forwarded_to_identity_provider.cs
@@ -6,8 +6,8 @@
namespace Cratis.AuthProxy.Scenarios.when_invitation_link_is_used;
///
-/// End-to-end scenario: verifies that claims declared in ClaimsToForward are extracted
-/// from the invite token and included in the x-ms-client-principal header sent to the
+/// End-to-end scenario: verifies that claims declared in ClaimsToForward are extracted
+/// from the invite token and included in the x-ms-client-principal header sent to the
/// identity details provider.
///
/// The shared application factory with claims-forwarding config.
diff --git a/Source/AuthProxy.Specs/Scenarios/when_multiple_providers_are_configured/and_a_non_browser_caller_requests_a_page.cs b/Source/AuthProxy.Specs/Scenarios/when_multiple_providers_are_configured/and_a_non_browser_caller_requests_a_page.cs
index 3083f51..b727e14 100644
--- a/Source/AuthProxy.Specs/Scenarios/when_multiple_providers_are_configured/and_a_non_browser_caller_requests_a_page.cs
+++ b/Source/AuthProxy.Specs/Scenarios/when_multiple_providers_are_configured/and_a_non_browser_caller_requests_a_page.cs
@@ -8,18 +8,18 @@ namespace Cratis.AuthProxy.Scenarios.when_multiple_providers_are_configured;
///
/// End-to-end: the callers that are not browsers navigating to a document must be refused with
-/// 401 rather than handed the provider-selection page at 200.
+/// 401 rather than handed the provider-selection page at 200.
///
-/// Three shapes are exercised because each records the 200 as a success in its own way — a
-/// fetch() from a frontend (Sec-Fetch-Dest: empty, Accept: */*), a webhook stating
+/// Three shapes are exercised because each records the 200 as a success in its own way — a
+/// fetch() from a frontend (Sec-Fetch-Dest: empty, Accept: */*), a webhook stating
/// only that it wants JSON, and a bare client stating nothing at all. The last is the one that matters
-/// most for delivery: it is what most webhook senders and every curl look like, and it is the shape
+/// most for delivery: it is what most webhook senders and every curl look like, and it is the shape
/// a naive Accept-only rule would still answer with HTML.
///
///
-/// /.cratis/me is requested by name because it is the concrete instance: Arc's
-/// IdentityProvider calls it on boot, and the page arriving as 200 text/html makes
-/// response.ok true so only the following .json() fails.
+/// /.cratis/me is requested by name because it is the concrete instance: Arc's
+/// IdentityProvider calls it on boot, and the page arriving as 200 text/html makes
+/// response.ok true so only the following .json() fails.
///
///
/// The shared application factory.
diff --git a/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_not_trusted.cs b/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_not_trusted.cs
index 78aabdc..6209000 100644
--- a/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_not_trusted.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_not_trusted.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.SignIns.for_ClientLocationResolver.when_resolving_fro
/// A caller that is not one of the deployment's own proxies gets none of its claims about itself believed.
///
///
-/// This spec used to assert the opposite — that the left-most X-Forwarded-For entry won — which made
+/// This spec used to assert the opposite — that the left-most X-Forwarded-For entry won — which made
/// the reported address the one value on the request an attacker most directly controls. Worse, the
/// forwarded-headers middleware consumes entries from the right, so the address recorded against a
/// sign-in was not even the address the rest of the proxy was using for the same request: one request, two
diff --git a/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_trusted.cs b/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_trusted.cs
index de13cad..ffff2d2 100644
--- a/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_trusted.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_ClientLocationResolver/when_resolving_from_forwarded_and_geo_headers/and_the_peer_is_trusted.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.SignIns.for_ClientLocationResolver.when_resolving_fro
///
///
/// The connection address and the raw header deliberately disagree here. Reading the header back would report
-/// 203.0.113.7 while every other part of the proxy — the cookie decisions, the reverse-proxy transform,
+/// 203.0.113.7 while every other part of the proxy — the cookie decisions, the reverse-proxy transform,
/// the access log — used the address the middleware settled on, so the assertion is as much about consistency
/// as about trust.
///
diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs
index 0cbe3e7..c0b39bf 100644
--- a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.SignIns.for_SignInAttestationClaims;
/// would leave all of them green while every deployed verifier broke — the claim names and the purpose value
/// are a contract with software AuthProxy does not build. These literals are what makes such a rename fail
/// here instead of in production. The separation assertions do the same for the collision the shape of
-/// otherwise makes invisible: both protocols sign a purpose
+/// otherwise makes invisible: both protocols sign a purpose
/// claim with the same key material, so two purposes that ever converged would let one protocol's assertion be
/// replayed as the other's.
///
diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs
index d3c303b..64c72fa 100644
--- a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration;
///
-/// The route binding is the RFC 9449 htu, which is the target's path — the query is deliberately not
+/// The route binding is the RFC 9449 htu, which is the target's path — the query is deliberately not
/// part of it. A notify URL that carries one therefore signs a route it does not fully name, and a captured
/// notification could be replayed against a different query while a conformant verifier still accepted it.
/// The only place that can be ruled out is where the endpoint is configured.
diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs
index 226ae67..d67d864 100644
--- a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs
@@ -8,7 +8,7 @@
namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.given;
///
-/// A notifier wired exactly as the host wires it once SignIn:Attestation is configured — the same
+/// A notifier wired exactly as the host wires it once SignIn:Attestation is configured — the same
/// released notifier, with the envelope signer available to it.
///
public class a_signed_sign_in_notifier : a_sign_in_notifier
diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs
index b318dbf..c5f6d0e 100644
--- a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs
+++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs
@@ -8,8 +8,8 @@ namespace Cratis.AuthProxy.SignIns.for_SignInNotifier;
///
/// The compatibility contract. The signer is wired exactly as the host wires it, but no
-/// SignIn:Attestation section exists — so the request that leaves must be indistinguishable from the
-/// one the released four-argument notifier sends: the same body bytes, and no Authorization header.
+/// SignIn:Attestation section exists — so the request that leaves must be indistinguishable from the
+/// one the released four-argument notifier sends: the same body bytes, and no Authorization header.
///
public class when_signing_is_not_configured : a_signed_sign_in_notifier
{
diff --git a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_attempts_traversal.cs b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_attempts_traversal.cs
index d4c7791..1b63282 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_attempts_traversal.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_attempts_traversal.cs
@@ -7,10 +7,10 @@ namespace Cratis.AuthProxy.for_AnonymousPathPolicy;
/// A declared prefix must mean exactly what it spells. Every traversal form is refused rather than
/// resolved, so a prefix can never open a path the operator did not name.
///
-/// Resolving would be the more forgiving choice and the wrong one: /public/../admin reads as scoped
-/// to /public while meaning /admin, so accepting it as /admin would hand an
-/// unauthenticated caller a surface nobody typed. The encoded spellings — %2e%2e%2f, %2f,
-/// %00 — are refused one step earlier, by the character allow-list, which is why they are pinned
+/// Resolving would be the more forgiving choice and the wrong one: /public/../admin reads as scoped
+/// to /public while meaning /admin, so accepting it as /admin would hand an
+/// unauthenticated caller a surface nobody typed. The encoded spellings — %2e%2e%2f, %2f,
+/// %00 — are refused one step earlier, by the character allow-list, which is why they are pinned
/// here alongside the literal form: they are the same attack and must not survive by taking a different
/// route through the check.
///
diff --git a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_cannot_name_a_prefix.cs b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_cannot_name_a_prefix.cs
index d820032..aa6401a 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_cannot_name_a_prefix.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_cannot_name_a_prefix.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.for_AnonymousPathPolicy;
/// which failure it was.
///
/// The entries that resolve to the application root are the ones that matter most:
-/// PathString.StartsWithSegments(string.Empty) is true for every request, so a blank value — an
+/// PathString.StartsWithSegments(string.Empty) is true for every request, so a blank value — an
/// environment variable set but never given one, a trailing index in a configuration array — would turn an
/// entire service anonymous, silently and globally. That is the worst outcome this feature can produce, so
/// every spelling of it is pinned here rather than left to review.
diff --git a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_carries_a_disallowed_character.cs b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_carries_a_disallowed_character.cs
index 74a0bf8..c4de1fb 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_carries_a_disallowed_character.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_carries_a_disallowed_character.cs
@@ -10,9 +10,9 @@ namespace Cratis.AuthProxy.for_AnonymousPathPolicy;
///
/// The failure this prevents is a prefix that means one thing to the middlewares, which match a literal
/// with , and another to the router, which matches
-/// an ASP.NET route template built from the same string. /a{x} is the sharpest example: as a
-/// template it is a route parameter, so the router would serve /aANYTHING/… anonymously
-/// while the middlewares matched only /a{x} — an unauthenticated surface far wider than anything
+/// an ASP.NET route template built from the same string. /a{x} is the sharpest example: as a
+/// template it is a route parameter, so the router would serve /aANYTHING/… anonymously
+/// while the middlewares matched only /a{x} — an unauthenticated surface far wider than anything
/// declared.
///
///
diff --git a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_targets_a_path_the_proxy_owns.cs b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_targets_a_path_the_proxy_owns.cs
index ff999a5..3cad525 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_targets_a_path_the_proxy_owns.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPathPolicy/when_an_entry_targets_a_path_the_proxy_owns.cs
@@ -9,9 +9,9 @@ namespace Cratis.AuthProxy.for_AnonymousPathPolicy;
/// An anonymous route is emitted at order 0, ahead of every service-selected route, and the three
/// middlewares stop applying their checks below a declared prefix. So pointing one at AuthProxy's own
/// namespace does not make an endpoint public — those endpoints already admit anonymous callers where they
-/// are meant to. It removes the endpoint from AuthProxy: a declared /.cratis hands the
-/// logout, token, tenant-selection and login endpoints to a backend, and a declared /invite or
-/// /register puts those flow middlewares behind a proxied route. Both are configuration mistakes
+/// are meant to. It removes the endpoint from AuthProxy: a declared /.cratis hands the
+/// logout, token, tenant-selection and login endpoints to a backend, and a declared /invite or
+/// /register puts those flow middlewares behind a proxied route. Both are configuration mistakes
/// with no legitimate spelling, so they are refused outright.
///
///
diff --git a/Source/AuthProxy.Specs/for_AnonymousPaths/when_configuration_carries_unusable_entries.cs b/Source/AuthProxy.Specs/for_AnonymousPaths/when_configuration_carries_unusable_entries.cs
index 39e0a27..56850c8 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPaths/when_configuration_carries_unusable_entries.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPaths/when_configuration_carries_unusable_entries.cs
@@ -6,16 +6,16 @@ namespace Cratis.AuthProxy.for_AnonymousPaths;
///
/// Entries that cannot express a path prefix must be discarded rather than matched.
///
-/// The empty entry is the one that matters: PathString.StartsWithSegments(string.Empty) is true
+/// The empty entry is the one that matters: PathString.StartsWithSegments(string.Empty) is true
/// for every request, so a stray blank value — an env var set but never given a value, a trailing index
/// in a configuration array — would otherwise turn the entire service anonymous, silently and globally.
/// That is the worst possible failure for this feature, so it is pinned here rather than left to review.
-/// The bare / is the same failure spelled differently.
+/// The bare / is the same failure spelled differently.
///
///
/// The route-template characters are the second class. A prefix is interpolated into an ASP.NET route
-/// template, so /a{x} would become a route parameter and make the router match
-/// /anything/… while the middlewares matched only the literal — the two components disagreeing
+/// template, so /a{x} would become a route parameter and make the router match
+/// /anything/… while the middlewares matched only the literal — the two components disagreeing
/// about the same prefix, which is the failure the shared matcher exists to prevent.
///
///
diff --git a/Source/AuthProxy.Specs/for_AnonymousPaths/when_matching_a_declared_prefix.cs b/Source/AuthProxy.Specs/for_AnonymousPaths/when_matching_a_declared_prefix.cs
index 76dba89..6784536 100644
--- a/Source/AuthProxy.Specs/for_AnonymousPaths/when_matching_a_declared_prefix.cs
+++ b/Source/AuthProxy.Specs/for_AnonymousPaths/when_matching_a_declared_prefix.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.for_AnonymousPaths;
/// A declared prefix must match on segment boundaries, case-insensitively, and nowhere else.
///
/// Case-insensitivity is not an accident: ASP.NET route templates match literal segments
-/// case-insensitively, so the route table would serve /PORTAL anonymously whatever the middlewares
+/// case-insensitively, so the route table would serve /PORTAL anonymously whatever the middlewares
/// decided. The two have to agree, and this pins which way.
///
///
diff --git a/Source/AuthProxy.Specs/for_HttpContextExtensions/when_asking_whether_an_invitation_is_pending.cs b/Source/AuthProxy.Specs/for_HttpContextExtensions/when_asking_whether_an_invitation_is_pending.cs
index cf681e0..6480462 100644
--- a/Source/AuthProxy.Specs/for_HttpContextExtensions/when_asking_whether_an_invitation_is_pending.cs
+++ b/Source/AuthProxy.Specs/for_HttpContextExtensions/when_asking_whether_an_invitation_is_pending.cs
@@ -6,7 +6,7 @@ namespace Cratis.AuthProxy.for_HttpContextExtensions;
///
/// Every caller of this asks it in order to relax something on the grounds that an invite exchange
/// is about to run: the tenancy refusal, the provider-selection refusal, the identity caches. Answering on
-/// the cookie's presence alone let a bare Cookie: .cratis-invite= buy all of that while
+/// the cookie's presence alone let a bare Cookie: .cratis-invite= buy all of that while
/// — which is what actually runs the
/// exchange — rejected the same blank value. So the relaxations happened, the exchange did not, and the
/// cookie is the caller's to send.
diff --git a/Source/AuthProxy.Specs/for_HttpContextExtensions/when_deciding_whether_a_page_answers_the_request.cs b/Source/AuthProxy.Specs/for_HttpContextExtensions/when_deciding_whether_a_page_answers_the_request.cs
index d1e1d17..adafb47 100644
--- a/Source/AuthProxy.Specs/for_HttpContextExtensions/when_deciding_whether_a_page_answers_the_request.cs
+++ b/Source/AuthProxy.Specs/for_HttpContextExtensions/when_deciding_whether_a_page_answers_the_request.cs
@@ -7,18 +7,18 @@ namespace Cratis.AuthProxy.for_HttpContextExtensions;
///
/// An HTML page is only an answer to a caller that is navigating to one. Everything else — a webhook, an
-/// integration, a fetch() from the application's own frontend — is asking for data, and answering it
+/// integration, a fetch() from the application's own frontend — is asking for data, and answering it
/// with a page is what turns a refusal into a recorded success.
///
-/// Sec-Fetch-Dest is the deciding signal because it is the only one that separates a document
+/// Sec-Fetch-Dest is the deciding signal because it is the only one that separates a document
/// navigation from a scripted request: both arrive from the same browser, on the same connection, and
-/// fetch() sends Accept: */*, which a naive read of Accept treats as "any content
+/// fetch() sends Accept: */*, which a naive read of Accept treats as "any content
/// type will do — including HTML". That read is exactly the defect. Only when the header is absent
-/// entirely (a client predating Fetch Metadata) does Accept decide, and then only an explicit
-/// text/html counts.
+/// entirely (a client predating Fetch Metadata) does Accept decide, and then only an explicit
+/// text/html counts.
///
///
-/// A caller sending Accept: */* together with Sec-Fetch-Dest: empty is the case the whole
+/// A caller sending Accept: */* together with Sec-Fetch-Dest: empty is the case the whole
/// rule exists for, and a duplicated destination header is the case that shows which way an unrecognized
/// signal falls: every rejection here means "not navigating", never "serve the page anyway".
///
diff --git a/Source/AuthProxy.Specs/for_IngressExtensions/given/an_ingress_configuration.cs b/Source/AuthProxy.Specs/for_IngressExtensions/given/an_ingress_configuration.cs
index 88c7d05..6039401 100644
--- a/Source/AuthProxy.Specs/for_IngressExtensions/given/an_ingress_configuration.cs
+++ b/Source/AuthProxy.Specs/for_IngressExtensions/given/an_ingress_configuration.cs
@@ -8,7 +8,7 @@
namespace Cratis.AuthProxy.for_IngressExtensions.given;
///
-/// An application configured through AddIngressConfiguration from a supplied ingress section.
+/// An application configured through AddIngressConfiguration from a supplied ingress section.
///
///
/// Built from configuration keys rather than from an options object, because the keys are the surface a
diff --git a/Source/AuthProxy.Specs/for_IngressExtensions/when_adding_ingress_configuration.cs b/Source/AuthProxy.Specs/for_IngressExtensions/when_adding_ingress_configuration.cs
index b8dfd75..179014d 100644
--- a/Source/AuthProxy.Specs/for_IngressExtensions/when_adding_ingress_configuration.cs
+++ b/Source/AuthProxy.Specs/for_IngressExtensions/when_adding_ingress_configuration.cs
@@ -43,7 +43,7 @@ void should_bind_auth_proxy_options()
/// The middleware runs a peer check only when it knows at least one proxy or network, so an empty pair is
/// what "believe every caller" means to it. This spec previously asserted the same two zeroes as a
/// requirement rather than as a compatibility fallback, which is why the boundary was never noticed to be
- /// missing. ForwardLimit is asserted alongside them because a bound-but-unread setting would look
+ /// missing. ForwardLimit is asserted alongside them because a bound-but-unread setting would look
/// identical here otherwise.
///
[Fact]
diff --git a/Source/AuthProxy.Specs/for_RelativeRedirect/when_deciding_whether_a_target_is_same_site.cs b/Source/AuthProxy.Specs/for_RelativeRedirect/when_deciding_whether_a_target_is_same_site.cs
index 7a28807..595bff3 100644
--- a/Source/AuthProxy.Specs/for_RelativeRedirect/when_deciding_whether_a_target_is_same_site.cs
+++ b/Source/AuthProxy.Specs/for_RelativeRedirect/when_deciding_whether_a_target_is_same_site.cs
@@ -6,15 +6,15 @@ namespace Cratis.AuthProxy.for_RelativeRedirect;
///
/// The single check that decides whether a caller-supplied redirect target can navigate off-site.
///
-/// Four endpoints hand the browser a target the caller chose — the login returnUrl, the link
-/// returnUrl, tenant selection's returnUrl, and logout's redirect — and each used to
+/// Four endpoints hand the browser a target the caller chose — the login returnUrl, the link
+/// returnUrl, tenant selection's returnUrl, and logout's redirect — and each used to
/// carry its own version of this check. They disagreed, which is the whole reason the check moved here: one
-/// accepted //evil.test outright, and the two that rejected it still accepted /\evil.test.
+/// accepted //evil.test outright, and the two that rejected it still accepted /\evil.test.
///
///
-/// What every disagreement had in common was treating a leading / as proof of same-site. It is not,
-/// because the browser decides what a Location means. //host is protocol-relative;
-/// /\host is the same URL to every major browser, which normalize a backslash to a slash in the
+/// What every disagreement had in common was treating a leading / as proof of same-site. It is not,
+/// because the browser decides what a Location means. //host is protocol-relative;
+/// /\host is the same URL to every major browser, which normalize a backslash to a slash in the
/// authority position; and a slash followed by a tab, carriage return or newline is also the same URL,
/// because browsers strip those characters before parsing — so the string checked here and the URL actually
/// fetched would be different strings.
diff --git a/Source/AuthProxy.Specs/for_TenancyMiddleware/when_path_is_anonymous.cs b/Source/AuthProxy.Specs/for_TenancyMiddleware/when_path_is_anonymous.cs
index 7ab1dff..8b0212e 100644
--- a/Source/AuthProxy.Specs/for_TenancyMiddleware/when_path_is_anonymous.cs
+++ b/Source/AuthProxy.Specs/for_TenancyMiddleware/when_path_is_anonymous.cs
@@ -8,8 +8,8 @@ namespace Cratis.AuthProxy.for_TenancyMiddleware;
/// removed, and must be forwarded rather than refused for having no resolvable tenant.
///
/// The header strip is the property that makes declaring a path anonymous safe. A caller reaching an
-/// anonymous path has no session, so anything it sends in x-ms-client-principal* or
-/// Tenant-ID is unverified — the application's identity handler would build a principal straight
+/// anonymous path has no session, so anything it sends in x-ms-client-principal* or
+/// Tenant-ID is unverified — the application's identity handler would build a principal straight
/// out of it. Because the request still travels through AuthProxy, the strip that runs for every other
/// request runs for this one too; that is the whole reason to solve this inside the proxy instead of
/// routing around it at the ingress, where those headers would arrive untouched.
diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_path_is_anonymous.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_path_is_anonymous.cs
index e1e07f7..6fcec27 100644
--- a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_path_is_anonymous.cs
+++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_path_is_anonymous.cs
@@ -12,7 +12,7 @@ namespace Cratis.AuthProxy.for_TenantSelectionMiddleware;
///
/// Skipping provider selection and the unresolved-tenant refusal only covers callers with no session at
/// all. A signed-in user with no tenant cookie who requests a declared anonymous path reaches tenant
-/// selection instead, and is answered with the tenant-selection page at 200 — the same HTML-instead
+/// selection instead, and is answered with the tenant-selection page at 200 — the same HTML-instead
/// -of-data, success-instead-of-refusal shape the anonymous path exists to remove, one middleware further
/// down. A declared path has to be reachable for every caller, not only for the signed-out ones.
///
diff --git a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_the_caller_is_not_navigating.cs b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_the_caller_is_not_navigating.cs
index c8d3cfe..d8dafec 100644
--- a/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_the_caller_is_not_navigating.cs
+++ b/Source/AuthProxy.Specs/for_TenantSelectionMiddleware/when_the_caller_is_not_navigating.cs
@@ -7,14 +7,14 @@
namespace Cratis.AuthProxy.for_TenantSelectionMiddleware;
///
-/// The tenant-selection page is served at 200 exactly as the provider-selection page was, and it is
+/// The tenant-selection page is served at 200 exactly as the provider-selection page was, and it is
/// the same defect: a caller that is not navigating to a document reads it as a delivered success.
///
/// This one is reached by a caller that is authenticated, so it is the shape an already-signed-in
-/// frontend hits — a fetch() for data that comes back as a tenant chooser with response.ok
-/// true. It gets 403 rather than 401: the caller is authenticated, and answering 401
+/// frontend hits — a fetch() for data that comes back as a tenant chooser with response.ok
+/// true. It gets 403 rather than 401: the caller is authenticated, and answering 401
/// would tell a frontend to restart a login it has already completed, which is the loop
-/// TenancyMiddleware already avoids for the no-organization case.
+/// TenancyMiddleware already avoids for the no-organization case.
///
///
public class when_the_caller_is_not_navigating : Specification
diff --git a/Source/AuthProxy.Specs/for_UniformDenial/when_the_response_already_carries_headers.cs b/Source/AuthProxy.Specs/for_UniformDenial/when_the_response_already_carries_headers.cs
index 879da5c..d649565 100644
--- a/Source/AuthProxy.Specs/for_UniformDenial/when_the_response_already_carries_headers.cs
+++ b/Source/AuthProxy.Specs/for_UniformDenial/when_the_response_already_carries_headers.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.for_UniformDenial;
///
/// The refusal is written after other middleware has had the request, and what those left behind is exactly
/// what would describe the deployment: a challenge header says there is something to authenticate against, a
-/// Location names a provider, a Set-Cookie hands out state to a caller who presented nothing.
+/// Location names a provider, a Set-Cookie hands out state to a caller who presented nothing.
/// Clearing them is the difference between one refusal and a family of them.
///
/// Written as its own spec because nothing else can see the clear happen. Every spec that observes a refusal
diff --git a/Source/AuthProxy.Specs/given/RecordingLogger.cs b/Source/AuthProxy.Specs/given/RecordingLogger.cs
index 4efa9db..980a651 100644
--- a/Source/AuthProxy.Specs/given/RecordingLogger.cs
+++ b/Source/AuthProxy.Specs/given/RecordingLogger.cs
@@ -8,7 +8,7 @@ namespace Cratis.AuthProxy.given;
///
/// The logger category.
///
-/// A source-generated [LoggerMessage] method returns before it formats anything when
+/// A source-generated [LoggerMessage] method returns before it formats anything when
/// answers no, and a substituted logger answers no by default. A
/// disclosure specification written against a substituted logger therefore records nothing and passes no
/// matter what the code under test writes — it proves nothing at all. This logger keeps every level enabled
diff --git a/Source/AuthProxy/Admission/AdmissionConfigurationValidator.cs b/Source/AuthProxy/Admission/AdmissionConfigurationValidator.cs
index f372d81..6e266ab 100644
--- a/Source/AuthProxy/Admission/AdmissionConfigurationValidator.cs
+++ b/Source/AuthProxy/Admission/AdmissionConfigurationValidator.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Admission;
///
///
/// Every failure here is one that would otherwise start cleanly and then refuse every caller alive, with a
-/// 404 that says nothing about why. Naming it at startup names it at the one moment somebody is
+/// 404 that says nothing about why. Naming it at startup names it at the one moment somebody is
/// watching.
///
public class AdmissionConfigurationValidator : IValidateOptions
diff --git a/Source/AuthProxy/Admission/AdmissionMiddleware.cs b/Source/AuthProxy/Admission/AdmissionMiddleware.cs
index 19f19b3..61a2933 100644
--- a/Source/AuthProxy/Admission/AdmissionMiddleware.cs
+++ b/Source/AuthProxy/Admission/AdmissionMiddleware.cs
@@ -13,11 +13,11 @@ namespace Cratis.AuthProxy.Admission;
///
/// It runs first — after the forwarded headers are applied, so the request describes its real origin, and
/// before the pages map, the static files, routing, authentication and everything downstream of them. That
-/// ordering is the whole feature: /_pages and the bundled assets are served ahead of authentication
+/// ordering is the whole feature: /_pages and the bundled assets are served ahead of authentication
/// by design, so a gate placed anywhere later would leave them public no matter what it decided.
///
-/// Nothing else about the pipeline moves. UseRouting() in particular stays exactly where it is, for
-/// the reason recorded beside it: moving it once made every bundled asset answer 401.
+/// Nothing else about the pipeline moves. UseRouting() in particular stays exactly where it is, for
+/// the reason recorded beside it: moving it once made every bundled asset answer 401.
///
///
/// A deployment in leaves this on the first line of
diff --git a/Source/AuthProxy/Admission/AdmissionPolicy.cs b/Source/AuthProxy/Admission/AdmissionPolicy.cs
index 286ea54..b145760 100644
--- a/Source/AuthProxy/Admission/AdmissionPolicy.cs
+++ b/Source/AuthProxy/Admission/AdmissionPolicy.cs
@@ -66,7 +66,7 @@ static bool IsProviderCallback(HttpContext context) =>
/// when such a cookie is present; otherwise .
///
/// It is a shape check and not a proof, and the difference matters: a caller writes their own cookie
- /// names, so anything named .AspNetCore.Correlation.-something satisfies this. What it buys is
+ /// names, so anything named .AspNetCore.Correlation.-something satisfies this. What it buys is
/// that an entry transaction alone does not carry a callback — a caller replaying a provider callback
/// path has to have been through a handshake this proxy started, or invent a cookie that says they
/// were. The value of that cookie is never read here.
diff --git a/Source/AuthProxy/AnonymousPathPolicy.cs b/Source/AuthProxy/AnonymousPathPolicy.cs
index 218b8a5..2b38932 100644
--- a/Source/AuthProxy/AnonymousPathPolicy.cs
+++ b/Source/AuthProxy/AnonymousPathPolicy.cs
@@ -13,21 +13,21 @@ namespace Cratis.AuthProxy;
/// so the entry itself is attacker-relevant input: whoever writes the configuration may be copying a path
/// from a bug report, a URL, or a template. Every rule here is fail-closed — a refused entry leaves that
/// path authenticated, never the reverse — and the refusal is reported by
-/// MicroserviceReverseProxyConfigProvider rather than swallowed.
+/// MicroserviceReverseProxyConfigProvider rather than swallowed.
///
/// The characters are an allow-list, not a deny-list, which is the whole point: a deny-list has to
/// anticipate every character that means something to one of the two matchers, and the cost of missing one
/// is a prefix that means different things to the middlewares and to the router. The permitted set is RFC
-/// 3986 unreserved (A-Z a-z 0-9 - . _ ~) plus the separator, which is every character a
+/// 3986 unreserved (A-Z a-z 0-9 - . _ ~) plus the separator, which is every character a
/// path prefix needs and nothing that carries meaning anywhere else. It excludes, by construction rather
-/// than by enumeration: % (a prefix whose meaning depends on encoding cannot be reasoned about, and
-/// %2e%2e%2f / %2f are the classic traversal and separator smuggling forms), {}* (a
-/// route parameter or catch-all, which would make the router match /aANYTHING/… where the
-/// middlewares match only the literal), \ (a separator to some backends and not to others),
-/// ?# (they end the path), ; (path parameters, which some backends strip and others do not),
-/// : and @ (authority syntax), control characters and whitespace (log and header injection,
+/// than by enumeration: % (a prefix whose meaning depends on encoding cannot be reasoned about, and
+/// %2e%2e%2f / %2f are the classic traversal and separator smuggling forms), {}* (a
+/// route parameter or catch-all, which would make the router match /aANYTHING/… where the
+/// middlewares match only the literal), \ (a separator to some backends and not to others),
+/// ?# (they end the path), ; (path parameters, which some backends strip and others do not),
+/// : and @ (authority syntax), control characters and whitespace (log and header injection,
/// and invisible differences between two entries that read identically), and every non-ASCII character
-/// (NFC and NFD spellings of the same path compare unequal, so which one is anonymous would
+/// (NFC and NFD spellings of the same path compare unequal, so which one is anonymous would
/// depend on how the configuration file was saved).
///
///
@@ -107,9 +107,9 @@ public static AnonymousPathRejection Evaluate(string? candidate, out string pref
/// The trimmed, rooted prefix.
/// when every segment is usable; otherwise the reason.
///
- /// A . or .. segment is refused rather than resolved. Resolving it would be the more
- /// forgiving choice and the wrong one: /public/../admin reads as scoped to /public while
- /// meaning /admin, so silently accepting it as /admin would open a path the operator did
+ /// A . or .. segment is refused rather than resolved. Resolving it would be the more
+ /// forgiving choice and the wrong one: /public/../admin reads as scoped to /public while
+ /// meaning /admin, so silently accepting it as /admin would open a path the operator did
/// not believe they were naming. Refusing keeps a declaration's meaning the same as its spelling.
///
static AnonymousPathRejection EvaluateSegments(string normalized)
@@ -141,8 +141,8 @@ static AnonymousPathRejection EvaluateSegments(string normalized)
/// middlewares stop applying their checks below a declared prefix. Pointing one at AuthProxy's own
/// namespace therefore does not make an endpoint public — those endpoints already allow anonymous
/// callers where they are meant to — it takes the endpoint away from AuthProxy: a declared
- /// /.cratis claims the logout, token, tenant-selection and login endpoints for a backend, and a
- /// declared /invite or /register puts the flow middlewares behind a proxied route. The
+ /// /.cratis claims the logout, token, tenant-selection and login endpoints for a backend, and a
+ /// declared /invite or /register puts the flow middlewares behind a proxied route. The
/// prefixes are reserved so that cannot be configured by accident.
///
static bool IsReserved(string normalized)
diff --git a/Source/AuthProxy/AnonymousPathRejection.cs b/Source/AuthProxy/AnonymousPathRejection.cs
index eee0da4..2251a6b 100644
--- a/Source/AuthProxy/AnonymousPathRejection.cs
+++ b/Source/AuthProxy/AnonymousPathRejection.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy;
///
/// A refused entry leaves its path authenticated, which is the safe outcome but an invisible one — the
/// operator declared a path public and it silently is not. Naming the reason is what makes the refusal
-/// reportable, so MicroserviceReverseProxyConfigProvider can say which entry was dropped and why
+/// reportable, so MicroserviceReverseProxyConfigProvider can say which entry was dropped and why
/// rather than leaving it to be discovered from a login prompt on a path that was meant to be open.
///
public enum AnonymousPathRejection
@@ -20,16 +20,16 @@ public enum AnonymousPathRejection
/// The entry was blank, or whitespace only.
Empty = 1,
- /// The entry does not start with /.
+ /// The entry does not start with /.
NotRooted = 2,
/// The entry resolves to the application root, which would make the whole service anonymous.
Root = 3,
- /// The entry contains an empty segment, from a repeated /.
+ /// The entry contains an empty segment, from a repeated /.
EmptySegment = 4,
- /// The entry contains a . or .. segment.
+ /// The entry contains a . or .. segment.
DotSegment = 5,
/// The entry contains a character outside the permitted set.
diff --git a/Source/AuthProxy/AnonymousPaths.cs b/Source/AuthProxy/AnonymousPaths.cs
index ae63b2b..74c3b22 100644
--- a/Source/AuthProxy/AnonymousPaths.cs
+++ b/Source/AuthProxy/AnonymousPaths.cs
@@ -10,9 +10,9 @@ namespace Cratis.AuthProxy;
/// Resolves the anonymous path prefixes declared in .
///
///
-/// Four components have to agree on what counts as an anonymous path — SelectProviderMiddleware
-/// (do not serve the provider-selection page), TenantSelectionMiddleware (do not serve the
-/// tenant chooser to a signed-in caller who has not chosen one), TenancyMiddleware (do not refuse
+/// Four components have to agree on what counts as an anonymous path — SelectProviderMiddleware
+/// (do not serve the provider-selection page), TenantSelectionMiddleware (do not serve the
+/// tenant chooser to a signed-in caller who has not chosen one), TenancyMiddleware (do not refuse
/// a caller with no resolvable tenant), and the reverse-proxy route table (do not apply the
/// authenticated-user authorization policy). If one disagreed the path would still be unreachable and the
/// disagreement would be silent, so they all resolve through here.
diff --git a/Source/AuthProxy/Attestations/AttestationSigner.cs b/Source/AuthProxy/Attestations/AttestationSigner.cs
index 0413fa4..f71e60f 100644
--- a/Source/AuthProxy/Attestations/AttestationSigner.cs
+++ b/Source/AuthProxy/Attestations/AttestationSigner.cs
@@ -12,8 +12,8 @@ namespace Cratis.AuthProxy.Attestations;
///
///
/// This is the one signing implementation in AuthProxy. It owns the bindings every signed AuthProxy assertion
-/// carries — provenance (iss plus the kid header selecting the key), audience (aud),
-/// freshness (iat, nbf, exp) and replay resistance (a random 256-bit jti) — and
+/// carries — provenance (iss plus the kid header selecting the key), audience (aud),
+/// freshness (iat, nbf, exp) and replay resistance (a random 256-bit jti) — and
/// leaves every protocol-specific binding to the caller's claims. Signing never throws on unusable key
/// material; it reports failure so a caller can refuse to send rather than fall back to an unsigned call.
///
@@ -44,8 +44,8 @@ public static string CreateOpaqueValue()
/// Tries to sign one assertion carrying the supplied protocol claims.
///
/// The resolved signing parameters.
- /// The instant the assertion is issued, from which iat, nbf and exp are derived.
- /// The protocol claims to bind, extended in place with the generated jti.
+ /// The instant the assertion is issued, from which iat, nbf and exp are derived.
+ /// The protocol claims to bind, extended in place with the generated jti.
/// The compact signed JWS when successful; otherwise an empty string.
/// when the assertion was signed; otherwise .
///
diff --git a/Source/AuthProxy/Attestations/AttestationSigningContract.cs b/Source/AuthProxy/Attestations/AttestationSigningContract.cs
index 4a403e7..23f6e45 100644
--- a/Source/AuthProxy/Attestations/AttestationSigningContract.cs
+++ b/Source/AuthProxy/Attestations/AttestationSigningContract.cs
@@ -6,11 +6,11 @@ namespace Cratis.AuthProxy.Attestations;
///
/// Represents the resolved signing parameters for one AuthProxy-signed assertion.
///
-/// The issuer written to the assertion's iss claim, naming the AuthProxy deployment that signed it.
-/// The audience written to the assertion's aud claim, naming the single application entitled to consume it.
-/// The identifier of the resolved active signing key, written to the JWS kid header so a verifier can select the matching public key.
+/// The issuer written to the assertion's iss claim, naming the AuthProxy deployment that signed it.
+/// The audience written to the assertion's aud claim, naming the single application entitled to consume it.
+/// The identifier of the resolved active signing key, written to the JWS kid header so a verifier can select the matching public key.
/// The PEM-encoded RSA private key belonging to .
-/// The lifetime applied to the assertion, from which its exp claim is derived.
+/// The lifetime applied to the assertion, from which its exp claim is derived.
///
/// The contract is the boundary between a configuration section and . Each
/// signed protocol resolves its own active key from its own configuration and hands the result over, so one
@@ -29,7 +29,7 @@ public sealed record AttestationSigningContract(
/// The contract's nonsecret values.
///
/// A record's generated prints every property, so one
- /// LogDebug("{Contract}", contract) would write the signing key to the log. This override exists so
+ /// LogDebug("{Contract}", contract) would write the signing key to the log. This override exists so
/// that no logging statement anyone adds later can disclose it.
///
public override string ToString() => $"{nameof(AttestationSigningContract)} {{ {nameof(Issuer)} = {Issuer}, {nameof(Audience)} = {Audience}, {nameof(KeyId)} = {KeyId}, {nameof(Lifetime)} = {Lifetime} }}";
diff --git a/Source/AuthProxy/Authentication/AadMultiTenantIssuer.cs b/Source/AuthProxy/Authentication/AadMultiTenantIssuer.cs
index 6ba975d..8893cde 100644
--- a/Source/AuthProxy/Authentication/AadMultiTenantIssuer.cs
+++ b/Source/AuthProxy/Authentication/AadMultiTenantIssuer.cs
@@ -7,15 +7,15 @@
namespace Cratis.AuthProxy.Authentication;
///
-/// Validates token issuers for the multi-tenant Microsoft Entra authorities (common,
-/// organizations, consumers).
+/// Validates token issuers for the multi-tenant Microsoft Entra authorities (common,
+/// organizations, consumers).
///
///
/// A multi-tenant authority's discovery metadata declares its issuer as the literal template
-/// https://login.microsoftonline.com/{tenantid}/v2.0, while every issued token carries the signing
+/// https://login.microsoftonline.com/{tenantid}/v2.0, while every issued token carries the signing
/// tenant's real issuer — so the default comparison rejects every token (IDX10205), for organizational and
/// personal accounts alike. The fix Microsoft documents is a tenant-aware validator: substitute the token's
-/// own tid claim into the template and require the issuer to match. The tenant is not an open
+/// own tid claim into the template and require the issuer to match. The tenant is not an open
/// wildcard — the issuer must be exactly the Microsoft issuer for the tenant that the token itself claims,
/// with the signing key already validated against Microsoft's metadata before this runs.
///
@@ -39,7 +39,7 @@ public static bool IsMultiTenantAuthority(string? authority) =>
///
/// Validates that a token's issuer is the Microsoft issuer for the tenant the token itself names in its
- /// tid claim. Assign to .
+ /// tid claim. Assign to .
///
/// The issuer from the token being validated.
/// The token being validated.
diff --git a/Source/AuthProxy/Authentication/AdditionalLogoutCookies.cs b/Source/AuthProxy/Authentication/AdditionalLogoutCookies.cs
index 85625c4..c52dcaf 100644
--- a/Source/AuthProxy/Authentication/AdditionalLogoutCookies.cs
+++ b/Source/AuthProxy/Authentication/AdditionalLogoutCookies.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Authentication;
///
/// Every deletion is issued at the root path with the Secure attribute mirroring the request scheme, so it
/// matches how such cookies are typically written and is not discarded by the browser. A cookie scoped to
-/// a parent domain (e.g. .cratis.studio) is invisible to a host-scoped deletion, so when an entry
+/// a parent domain (e.g. .cratis.studio) is invisible to a host-scoped deletion, so when an entry
/// carries a domain the deletion is issued for that domain as well — deleting for a parent domain of the
/// current host is legal, which is exactly what makes it possible to kill such a straggler from here.
///
diff --git a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs
index c074357..a0c2cbc 100644
--- a/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs
+++ b/Source/AuthProxy/Authentication/AuthenticationServiceCollectionExtensions.cs
@@ -371,7 +371,7 @@ static void RegisterOAuthProviders(AuthenticationBuilder authBuilder, IListThe provider completing the sign-in.
/// A representing the asynchronous operation.
///
- /// Runs after RunClaimActions, on the identity that is about to be signed into the cookie, so
+ /// Runs after RunClaimActions, on the identity that is about to be signed into the cookie, so
/// what an enricher adds is persisted with the session and travels with every later request — no
/// second call to the provider, and nothing to re-fetch. An enricher that has nothing to contribute for
/// this provider is not called at all, so a deployment with no such provider pays for none of this.
diff --git a/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs
index 5a7aa79..b3a06a7 100644
--- a/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs
+++ b/Source/AuthProxy/Authentication/ClientCredentialsGrantService.cs
@@ -21,7 +21,7 @@ public class ClientCredentialsGrantService(
/// The requested service name.
/// The provided client identifier.
/// The provided client secret.
- /// The provided refresh token, for the refresh_token grant.
+ /// The provided refresh token, for the refresh_token grant.
/// The cancellation token.
/// The token issuance result.
public Task GrantAsync(
diff --git a/Source/AuthProxy/Authentication/GitHubMembershipClaimsEnricher.cs b/Source/AuthProxy/Authentication/GitHubMembershipClaimsEnricher.cs
index 17c0230..7ec1d5b 100644
--- a/Source/AuthProxy/Authentication/GitHubMembershipClaimsEnricher.cs
+++ b/Source/AuthProxy/Authentication/GitHubMembershipClaimsEnricher.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Authentication;
///
/// GitHub's user endpoint — the one names — returns a
/// profile and nothing about membership, so there is no claim to match on and no mapping that could produce
-/// one. Membership lives behind /user/orgs and /user/teams, which is why gating on a GitHub
+/// one. Membership lives behind /user/orgs and /user/teams, which is why gating on a GitHub
/// organization is not a matter of configuration alone.
///
/// Fetching it once, at sign-in, and turning it into claims is what keeps a single authorization mechanism:
@@ -69,13 +69,13 @@ public async Task Enrich(
/// Resolves a membership collection endpoint from the configured user-information endpoint.
///
/// The provider whose endpoints to read.
- /// The collection under the user endpoint (orgs or teams).
+ /// The collection under the user endpoint (orgs or teams).
/// The resolved URL when the endpoint is usable.
/// when the URL could be resolved; otherwise .
///
/// Derived rather than configured, so GitHub Enterprise works without a second setting: the collections
/// sit directly under whatever user endpoint is already configured, whether that is
- /// https://api.github.com/user or https://github.example.com/api/v3/user.
+ /// https://api.github.com/user or https://github.example.com/api/v3/user.
///
static bool TryResolveResource(C.OAuthProvider provider, string resource, out Uri url)
{
diff --git a/Source/AuthProxy/Authentication/GitHubPagedResourceReader.cs b/Source/AuthProxy/Authentication/GitHubPagedResourceReader.cs
index 9c4ad67..60f10e0 100644
--- a/Source/AuthProxy/Authentication/GitHubPagedResourceReader.cs
+++ b/Source/AuthProxy/Authentication/GitHubPagedResourceReader.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Authentication;
/// Reads a paged GitHub REST collection and projects each entry to a single string.
///
///
-/// GitHub pages every collection endpoint and links the next page from a Link header rather than
+/// GitHub pages every collection endpoint and links the next page from a Link header rather than
/// from the body, so a single request answers "the first hundred organizations", which for authorization
/// is a different question from the one being asked. Following the links is what makes membership in the
/// hundred-and-first organization count.
@@ -126,7 +126,7 @@ static Uri WithPageSize(Uri resource) =>
}.Uri;
///
- /// Resolves the next page from the response's Link header.
+ /// Resolves the next page from the response's Link header.
///
/// The response just read.
/// The collection URL the read started from.
diff --git a/Source/AuthProxy/Authentication/SelectProviderMiddleware.cs b/Source/AuthProxy/Authentication/SelectProviderMiddleware.cs
index 6dcdd36..df6aedc 100644
--- a/Source/AuthProxy/Authentication/SelectProviderMiddleware.cs
+++ b/Source/AuthProxy/Authentication/SelectProviderMiddleware.cs
@@ -15,7 +15,7 @@ namespace Cratis.AuthProxy.Authentication;
/// (when exactly one provider is configured).
/// When the proxy is in lobby mode ( is
/// enabled and a lobby URL is configured), unauthenticated requests without an invite token or
-/// pending invite cookie are immediately answered with the invitation-required.html page
+/// pending invite cookie are immediately answered with the invitation-required.html page
/// instead of being redirected to a login provider.
/// Skips invite paths, registration paths, a provider's login-challenge endpoint, the providers and
/// token endpoints, paths a service declares in , and requests
@@ -23,7 +23,7 @@ namespace Cratis.AuthProxy.Authentication;
/// directly (e.g. a redirect from the cookie authentication handler, or an invite flow) is exactly what
/// this middleware answers.
/// Both answers are only served to a browser navigating to a document; every other caller is refused
-/// with 401, because a page or a login redirect reads as a delivered success to a client that
+/// with 401, because a page or a login redirect reads as a delivered success to a client that
/// checks the status code.
///
/// The next middleware in the pipeline.
@@ -138,7 +138,7 @@ await errorPageProvider.WriteErrorPageAsync(
/// Ordinarily the current request IS the destination — this middleware answers in place of
/// whatever the caller was navigating to, so its path and query say where that was. The one
/// exception is a request that already landed on the selection page's own path carrying an
- /// explicit returnUrl — the cookie authentication handler's redirect, or the invite flow,
+ /// explicit returnUrl — the cookie authentication handler's redirect, or the invite flow,
/// send callers there this way — in which case that query value, not the wrapper URL around it, is
/// the real destination.
///
@@ -172,9 +172,9 @@ static bool HasSignInFailureReason(HttpContext context) =>
/// The current .
/// A that represents the asynchronous operation.
///
- /// A 401 is required to carry a WWW-Authenticate challenge, and the only credential this
+ /// A 401 is required to carry a WWW-Authenticate challenge, and the only credential this
/// proxy accepts on the wire is a bearer token — a JWT from the configured authority, or one AuthProxy
- /// itself mints at /.cratis/token for a service with client credentials. The challenge is
+ /// itself mints at /.cratis/token for a service with client credentials. The challenge is
/// therefore emitted exactly when one of those is configured; a deployment where neither is means there
/// is no token-based way in at all, and naming a scheme that cannot work would send a caller after
/// credentials no endpoint would accept.
diff --git a/Source/AuthProxy/Authentication/SignInFailureReason.cs b/Source/AuthProxy/Authentication/SignInFailureReason.cs
index f905a2c..b8885f7 100644
--- a/Source/AuthProxy/Authentication/SignInFailureReason.cs
+++ b/Source/AuthProxy/Authentication/SignInFailureReason.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Authentication;
/// A failed sign-in must never surface as a bare error page — the browser is redirected back to provider
/// selection with one of these reasons so the page can show the person what happened and offer the way
/// forward: trying again. The values are part of the page contract, so a custom
-/// select-provider.html can rely on them.
+/// select-provider.html can rely on them.
///
public static class SignInFailureReason
{
diff --git a/Source/AuthProxy/Authentication/TenantAuthenticationState.cs b/Source/AuthProxy/Authentication/TenantAuthenticationState.cs
index 7b319b0..c614856 100644
--- a/Source/AuthProxy/Authentication/TenantAuthenticationState.cs
+++ b/Source/AuthProxy/Authentication/TenantAuthenticationState.cs
@@ -107,9 +107,9 @@ public static bool TryResolvePostAuthenticationRedirectUri(
/// A same-site relative target, or the application root when none can be derived.
///
/// This value survives the round-trip to the identity provider and is handed to the browser as the
- /// post-authentication Location, so it is the single most attractive open-redirect target in
+ /// post-authentication Location, so it is the single most attractive open-redirect target in
/// AuthProxy: the victim sees the real domain and completes a real login before it is honored. It
- /// arrives from an AllowAnonymous endpoint, so it is attacker-supplied by default.
+ /// arrives from an AllowAnonymous endpoint, so it is attacker-supplied by default.
///
/// An http(s) absolute URL is reduced to its path and query rather than refused, which keeps a
/// caller that sends its own origin working — the host is dropped, never honored. Any other scheme is
@@ -117,8 +117,8 @@ public static bool TryResolvePostAuthenticationRedirectUri(
///
///
/// The scheme check is doing more work than it looks like. On Unix,
- /// parses a rooted path as an absolute file: URI, so without it //evil.test/phish and
- /// /\t/evil.test would be run through — laundering a target this
+ /// parses a rooted path as an absolute file: URI, so without it //evil.test/phish and
+ /// /\t/evil.test would be run through — laundering a target this
/// method had just rejected into one that looks clean, and doing it on Linux but not on Windows.
///
///
diff --git a/Source/AuthProxy/Authorization/AccessControlMiddleware.cs b/Source/AuthProxy/Authorization/AccessControlMiddleware.cs
index 5607423..d933d63 100644
--- a/Source/AuthProxy/Authorization/AccessControlMiddleware.cs
+++ b/Source/AuthProxy/Authorization/AccessControlMiddleware.cs
@@ -14,13 +14,13 @@ namespace Cratis.AuthProxy.Authorization;
///
/// It runs directly after authorization and ahead of tenancy, identity resolution and the reverse proxy,
/// so a caller who is not allowed in is turned away before any of them run — no tenant is resolved, no
-/// /.cratis/me call is made against a backend, and nothing is forwarded. That ordering is the whole
+/// /.cratis/me call is made against a backend, and nothing is forwarded. That ordering is the whole
/// point of calling it a first gate.
///
-/// The refusal is an HTML page at 403 rather than a redirect. A redirect back to the identity
+/// The refusal is an HTML page at 403 rather than a redirect. A redirect back to the identity
/// provider is the obvious wrong answer here: the caller is already signed in and would sign in again as
-/// the same person, so it loops. 403 is also a status a non-browser caller can act on, unlike the
-/// 200 a provider-selection page has to be served with, so the same answer works for both and the
+/// the same person, so it loops. 403 is also a status a non-browser caller can act on, unlike the
+/// 200 a provider-selection page has to be served with, so the same answer works for both and the
/// page carries the way out — signing out and coming back as someone else.
///
///
diff --git a/Source/AuthProxy/Authorization/AccessDecision.cs b/Source/AuthProxy/Authorization/AccessDecision.cs
index 9c588c9..618134e 100644
--- a/Source/AuthProxy/Authorization/AccessDecision.cs
+++ b/Source/AuthProxy/Authorization/AccessDecision.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.Authorization;
///
/// The unsatisfied claim is carried so a refusal can be logged as something an operator can act on. A
/// deployment that gates on organization membership and a deployment that gates on a role produce the
-/// same 403, and "which requirement was it" is the only part that differs and the only part worth
+/// same 403, and "which requirement was it" is the only part that differs and the only part worth
/// looking up. It is deliberately the claim type and never the value the caller carried, which
/// would put an identity into the log.
///
diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs
index 4cdc18a..3a73a25 100644
--- a/Source/AuthProxy/Authorization/AccessPolicy.cs
+++ b/Source/AuthProxy/Authorization/AccessPolicy.cs
@@ -16,7 +16,7 @@ namespace Cratis.AuthProxy.Authorization;
/// requirement in the combined set has to hold. A service can therefore only ever narrow who reaches it,
/// which is the property that makes a root requirement worth writing — if a service section could replace
/// it, the root would be a default rather than a floor, and a service added later without an
-/// Authorization section would silently be the way in.
+/// Authorization section would silently be the way in.
///
public class AccessPolicy : IAccessPolicy
{
@@ -78,9 +78,9 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config)
///
/// This runs before endpoint selection — the gate has to refuse a caller before anything reads a
/// backend, and long before YARP picks a route — so the target is worked out from the request rather
- /// than from a selected endpoint. It mirrors MicroserviceReverseProxyConfigProvider exactly: a
+ /// than from a selected endpoint. It mirrors MicroserviceReverseProxyConfigProvider exactly: a
/// single-service deployment routes everything to that service, and beyond that a service is named by
- /// the Service-ID header or the service query parameter, header first.
+ /// the Service-ID header or the service query parameter, header first.
///
/// A request in a multi-service deployment that names no service reaches no service route either, so
/// answering costs nothing: the root requirements still apply, and the request
@@ -119,14 +119,14 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config)
/// when the requirement is satisfied; otherwise .
///
/// A requirement naming no claim can never be satisfied, so it denies. That is the fail-closed
- /// direction, and the opposite of how an unusable AnonymousPaths entry is treated: discarding an
+ /// direction, and the opposite of how an unusable AnonymousPaths entry is treated: discarding an
/// entry there leaves a path authenticated, discarding a requirement here would let everybody in.
/// Startup validation refuses the configuration outright, so this is the second line rather than the
/// first.
///
/// Values are compared case-insensitively. The values being matched are organization names, team
/// slugs, group names and roles — identifiers their own systems treat as case-insensitive — so an
- /// ordinal comparison would turn cratis against Cratis into a locked-out deployment with
+ /// ordinal comparison would turn cratis against Cratis into a locked-out deployment with
/// nothing in the response to say why.
///
///
diff --git a/Source/AuthProxy/Authorization/AuthorizationConfigurationValidator.cs b/Source/AuthProxy/Authorization/AuthorizationConfigurationValidator.cs
index 5f74841..4a3b35f 100644
--- a/Source/AuthProxy/Authorization/AuthorizationConfigurationValidator.cs
+++ b/Source/AuthProxy/Authorization/AuthorizationConfigurationValidator.cs
@@ -12,7 +12,7 @@ namespace Cratis.AuthProxy.Authorization;
///
/// Such a requirement can never be satisfied, so the proxy would start and then refuse every single
/// caller — an outage whose cause is a blank value in an environment variable and whose symptom is a
-/// 403 page saying nothing about it. Failing at startup names it instead, at the one moment
+/// 403 page saying nothing about it. Failing at startup names it instead, at the one moment
/// somebody is watching.
///
/// The alternative — dropping the malformed requirement — is the one thing that must not happen: a
diff --git a/Source/AuthProxy/Configuration/Admission.cs b/Source/AuthProxy/Configuration/Admission.cs
index 9abd959..182d20a 100644
--- a/Source/AuthProxy/Configuration/Admission.cs
+++ b/Source/AuthProxy/Configuration/Admission.cs
@@ -41,7 +41,7 @@ public class Admission
///
///
/// Twenty rather than ten because of what sits inside the round-trip it has to outlast: ASP.NET Core's
- /// own RemoteAuthenticationOptions.RemoteAuthenticationTimeout allows fifteen minutes at the
+ /// own RemoteAuthenticationOptions.RemoteAuthenticationTimeout allows fifteen minutes at the
/// provider, and enrolling in MFA, resetting a password or working through a consent screen routinely
/// uses them. An entry shorter than that expires while the framework still considers the handshake
/// live, and the caller comes back to the uniform refusal — with no recovery, and by design nothing in
diff --git a/Source/AuthProxy/Configuration/AuthProxy.cs b/Source/AuthProxy/Configuration/AuthProxy.cs
index 558edac..3b944c2 100644
--- a/Source/AuthProxy/Configuration/AuthProxy.cs
+++ b/Source/AuthProxy/Configuration/AuthProxy.cs
@@ -47,7 +47,7 @@ public class AuthProxy
///
/// Gets or sets the credential-linking configuration.
- /// Set this section to enable the session-preserving /.cratis/link/{scheme} flow.
+ /// Set this section to enable the session-preserving /.cratis/link/{scheme} flow.
///
public Link? Link { get; set; }
@@ -81,7 +81,7 @@ public class AuthProxy
///
/// Gets or sets the absolute path to a directory containing custom error pages.
/// Pages are looked up by their file name inside this directory.
- /// When empty or unset the ingress uses the built-in Pages directory.
+ /// When empty or unset the ingress uses the built-in Pages directory.
/// Override this by mounting a custom pages directory into the container and pointing
/// this setting at the mount path.
///
diff --git a/Source/AuthProxy/Configuration/CanonicalIdentity.cs b/Source/AuthProxy/Configuration/CanonicalIdentity.cs
index c8bc19a..d2f2421 100644
--- a/Source/AuthProxy/Configuration/CanonicalIdentity.cs
+++ b/Source/AuthProxy/Configuration/CanonicalIdentity.cs
@@ -45,13 +45,13 @@ public class CanonicalIdentity
///
/// Gets or sets the exact provider-derived claim type that supplies the email address for an invitation
- /// attestation. The default is email.
+ /// attestation. The default is email.
///
public string EmailClaimType { get; set; } = "email";
///
/// Gets or sets the exact provider-derived claim type that proves the email address is verified.
- /// The claim must occur exactly once with the value . The default is email_verified.
+ /// The claim must occur exactly once with the value . The default is email_verified.
///
public string EmailVerifiedClaimType { get; set; } = "email_verified";
@@ -59,7 +59,7 @@ public class CanonicalIdentity
/// Gets or sets the exact provider-derived claim type that describes authentication assurance.
///
///
- /// OIDC providers commonly use acr. OAuth providers can map a trustworthy user-information field to
+ /// OIDC providers commonly use acr. OAuth providers can map a trustworthy user-information field to
/// this claim. AuthProxy refuses an invitation completion when the configured claim is missing or ambiguous.
///
public string AssuranceClaimType { get; set; } = "acr";
diff --git a/Source/AuthProxy/Configuration/ClaimRequirement.cs b/Source/AuthProxy/Configuration/ClaimRequirement.cs
index e636ce4..f67f729 100644
--- a/Source/AuthProxy/Configuration/ClaimRequirement.cs
+++ b/Source/AuthProxy/Configuration/ClaimRequirement.cs
@@ -20,7 +20,7 @@ public class ClaimRequirement
{
///
/// Gets or sets the claim type that must be present on the authenticated principal, for example
- /// urn:github:organization or roles.
+ /// urn:github:organization or roles.
///
public string Claim { get; set; } = string.Empty;
diff --git a/Source/AuthProxy/Configuration/IdentityVerificationMode.cs b/Source/AuthProxy/Configuration/IdentityVerificationMode.cs
index 29b69d4..325c548 100644
--- a/Source/AuthProxy/Configuration/IdentityVerificationMode.cs
+++ b/Source/AuthProxy/Configuration/IdentityVerificationMode.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.Configuration;
///
-/// Represents what a service's answer on /.cratis/me means to AuthProxy.
+/// Represents what a service's answer on /.cratis/me means to AuthProxy.
///
///
/// Calling the identity endpoint answers two different questions at once, and they have opposite failure
@@ -25,10 +25,10 @@ namespace Cratis.AuthProxy.Configuration;
public enum IdentityVerificationMode
{
///
- /// The endpoint enriches identity details, and only an HTTP 403 refuses the caller. Every other
+ /// The endpoint enriches identity details, and only an HTTP 403 refuses the caller. Every other
/// answer lets the request through and merges whatever details came with it — an unreachable service, a
- /// timeout, a non-success status other than 403, an empty body, an unparseable body, and a
- /// well-formed body whose own isAuthorized or isAuthenticated verdict is negative or
+ /// timeout, a non-success status other than 403, an empty body, an unparseable body, and a
+ /// well-formed body whose own isAuthorized or isAuthenticated verdict is negative or
/// self-contradicting. This is the released behavior, exactly, and remains the default.
///
///
@@ -48,7 +48,7 @@ public enum IdentityVerificationMode
///
/// A denial expires the readable identity cookie and the sealed authorization record by asking the
/// browser to delete them, and evicts the in-memory result. Two of those three are requests rather than
- /// guarantees: a non-browser caller that ignores Set-Cookie keeps presenting the sealed record it
+ /// guarantees: a non-browser caller that ignores Set-Cookie keeps presenting the sealed record it
/// was issued and is short-circuited on it until it expires. So what this mode bounds is revocation
/// latency — a positive can be reused for at most
/// (the sealed record) or
diff --git a/Source/AuthProxy/Configuration/Ingress.cs b/Source/AuthProxy/Configuration/Ingress.cs
index ca678c1..50dc7e7 100644
--- a/Source/AuthProxy/Configuration/Ingress.cs
+++ b/Source/AuthProxy/Configuration/Ingress.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Configuration;
///
///
/// A reverse proxy only knows the client's real address and the scheme the browser used because whatever sits
-/// in front of it says so, in X-Forwarded-For and X-Forwarded-Proto. Those headers are ordinary
+/// in front of it says so, in X-Forwarded-For and X-Forwarded-Proto. Those headers are ordinary
/// request headers, so any caller that can open a connection can send them. The boundary is therefore not a
/// detail of the header format but a statement about the network: which peers are the deployment's own
/// infrastructure, and how many of them a request legitimately passes through.
@@ -31,7 +31,7 @@ public class Ingress
///
/// Gets or sets the peers whose forwarded headers are believed, as IP addresses
- /// (10.0.0.7, 2001:db8::1) or CIDR ranges (10.0.0.0/8, 2001:db8::/32).
+ /// (10.0.0.7, 2001:db8::1) or CIDR ranges (10.0.0.0/8, 2001:db8::/32).
///
///
/// These are the addresses AuthProxy sees as the immediate peer — the ingress controller, load balancer,
@@ -42,12 +42,12 @@ public class Ingress
public IList TrustedProxies { get; set; } = [];
///
- /// Gets or sets how many forwarded entries are consumed from the right of X-Forwarded-For,
- /// which is how many trusted proxies a request legitimately passes through. Defaults to 1.
+ /// Gets or sets how many forwarded entries are consumed from the right of X-Forwarded-For,
+ /// which is how many trusted proxies a request legitimately passes through. Defaults to 1.
///
///
- /// Set it to the number of hops the deployment actually has — an ingress controller alone is 1, a
- /// CDN in front of a load balancer is 2. Every hop counted must itself be a trusted peer, so
+ /// Set it to the number of hops the deployment actually has — an ingress controller alone is 1, a
+ /// CDN in front of a load balancer is 2. Every hop counted must itself be a trusted peer, so
/// raising this without listing the intermediate addresses in changes
/// nothing. It directly decides which address is reported as the client: with too few hops the reported
/// address is the deployment's own inner proxy, and with more hops than exist the reported address is
diff --git a/Source/AuthProxy/Configuration/InvitationAttestation.cs b/Source/AuthProxy/Configuration/InvitationAttestation.cs
index 457f653..e743ec7 100644
--- a/Source/AuthProxy/Configuration/InvitationAttestation.cs
+++ b/Source/AuthProxy/Configuration/InvitationAttestation.cs
@@ -28,7 +28,7 @@ public class InvitationAttestation
///
///
/// Keep the previous key during a rotation until every attestation it signed has expired. The invitation
- /// authority pins the corresponding public keys and selects one by the required JWT kid header.
+ /// authority pins the corresponding public keys and selects one by the required JWT kid header.
///
public IList SigningKeys { get; set; } = [];
diff --git a/Source/AuthProxy/Configuration/InvitationAttestationSigningKey.cs b/Source/AuthProxy/Configuration/InvitationAttestationSigningKey.cs
index a958b03..93655a8 100644
--- a/Source/AuthProxy/Configuration/InvitationAttestationSigningKey.cs
+++ b/Source/AuthProxy/Configuration/InvitationAttestationSigningKey.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Configuration;
public class InvitationAttestationSigningKey
{
///
- /// Gets or sets the key identifier written to the JWT kid header.
+ /// Gets or sets the key identifier written to the JWT kid header.
///
public string KeyId { get; set; } = string.Empty;
diff --git a/Source/AuthProxy/Configuration/Invite.cs b/Source/AuthProxy/Configuration/Invite.cs
index 32f148e..11c5b75 100644
--- a/Source/AuthProxy/Configuration/Invite.cs
+++ b/Source/AuthProxy/Configuration/Invite.cs
@@ -14,20 +14,20 @@ public class Invite
public string PublicKeyPem { get; set; } = string.Empty;
///
- /// Gets or sets the expected token issuer (iss claim).
+ /// Gets or sets the expected token issuer (iss claim).
/// Leave empty to skip issuer validation.
///
public string Issuer { get; set; } = string.Empty;
///
- /// Gets or sets the expected token audience (aud claim).
+ /// Gets or sets the expected token audience (aud claim).
/// Leave empty to skip audience validation.
///
public string Audience { get; set; } = string.Empty;
///
/// Gets or sets the absolute URL of the invitation authority's completion endpoint,
- /// e.g. https://lobby.example.com/_invite/exchange.
+ /// e.g. https://lobby.example.com/_invite/exchange.
///
public string ExchangeUrl { get; set; } = string.Empty;
@@ -54,7 +54,7 @@ public class Invite
/// 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
- /// invitation-subject-already-exists.html page.
+ /// invitation-subject-already-exists.html page.
/// Leave empty to serve the built-in well-known error page.
///
public string SubjectAlreadyExistsUrl { get; set; } = string.Empty;
@@ -100,24 +100,24 @@ public class Invite
/// verification status are forwarded to the exchange endpoint regardless of this setting.
///
///
- /// AuthProxy reads the address from email, then ClaimTypes.Email, and finally
- /// preferred_username only when that value has an email-address shape. If no address is available,
- /// AuthProxy rejects the invite with invitation-email-unavailable.html. If the address differs from
- /// the invited address, or the provider explicitly supplies email_verified=false, AuthProxy rejects
- /// it with invitation-email-mismatch.html.
+ /// AuthProxy reads the address from email, then ClaimTypes.Email, and finally
+ /// preferred_username only when that value has an email-address shape. If no address is available,
+ /// AuthProxy rejects the invite with invitation-email-unavailable.html. If the address differs from
+ /// the invited address, or the provider explicitly supplies email_verified=false, AuthProxy rejects
+ /// it with invitation-email-mismatch.html.
///
- /// The email_verified claim is provider-supplied evidence, not a universal AuthProxy attestation.
+ /// The email_verified claim is provider-supplied evidence, not a universal AuthProxy attestation.
/// An explicit is rejected; a missing or unparsable value is forwarded as
/// and does not independently prove ownership. OAuth provider registrations do not
- /// currently map email_verified, so their address is accepted as provider-supplied session evidence
- /// with a verification status. For example, GitHub's /user response can omit
- /// a private email address, and AuthProxy does not make a separate /user/emails request.
+ /// currently map email_verified, so their address is accepted as provider-supplied session evidence
+ /// with a verification status. For example, GitHub's /user response can omit
+ /// a private email address, and AuthProxy does not make a separate /user/emails request.
///
///
public string EmailClaim { get; set; } = string.Empty;
///
- /// Gets or sets a value indicating whether the invitation ID from the invite token (jti)
+ /// Gets or sets a value indicating whether the invitation ID from the invite token (jti)
/// should be appended to the lobby redirect URL query string after a successful invite exchange.
/// Defaults to .
///
@@ -125,7 +125,7 @@ public class Invite
///
/// Gets or sets the query-string key used when is enabled.
- /// Defaults to invitationId.
+ /// Defaults to invitationId.
///
public string InvitationIdQueryStringKey { get; set; } = "invitationId";
diff --git a/Source/AuthProxy/Configuration/Link.cs b/Source/AuthProxy/Configuration/Link.cs
index 53abeaf..dfc440a 100644
--- a/Source/AuthProxy/Configuration/Link.cs
+++ b/Source/AuthProxy/Configuration/Link.cs
@@ -17,16 +17,16 @@ public class Link
{
///
/// Gets or sets the absolute URL of the application endpoint that records the freshly authenticated
- /// subject for a link, e.g. https://studio.example.com/api/internal/identity-providers/link.
- /// AuthProxy posts { subject, identityProvider } to it with the one-time link token supplied by
+ /// subject for a link, e.g. https://studio.example.com/api/internal/identity-providers/link.
+ /// AuthProxy posts { subject, identityProvider } to it with the one-time link token supplied by
/// the application as the bearer token, exactly as the invite exchange does.
/// Leave empty to disable the link callback.
///
public string ExchangeUrl { get; set; } = string.Empty;
///
- /// Gets or sets the origins allowed to embed the link-flow pages in an iframe. The literal
- /// "self" names the proxy's own origin — the common case, where the product is served through
+ /// Gets or sets the origins allowed to embed the link-flow pages in an iframe. The literal
+ /// "self" names the proxy's own origin — the common case, where the product is served through
/// the proxy. Empty by default, which forbids framing the link pages entirely; nothing else the proxy
/// serves is ever opened to framing by this setting.
///
diff --git a/Source/AuthProxy/Configuration/Logout.cs b/Source/AuthProxy/Configuration/Logout.cs
index c57dcd1..367ae73 100644
--- a/Source/AuthProxy/Configuration/Logout.cs
+++ b/Source/AuthProxy/Configuration/Logout.cs
@@ -10,7 +10,7 @@ public class Logout
{
///
/// Gets or sets additional origins permitted as post-logout redirect targets.
- /// Each entry is an absolute origin (scheme and host, optionally a port), e.g. https://cratis.studio.
+ /// Each entry is an absolute origin (scheme and host, optionally a port), e.g. https://cratis.studio.
/// These are added to the implicit allow-list — the proxy's own public origin plus the configured
/// service frontends and lobby frontend. Malformed or non-HTTP(S) entries are ignored.
///
diff --git a/Source/AuthProxy/Configuration/LogoutCookie.cs b/Source/AuthProxy/Configuration/LogoutCookie.cs
index 9cedb73..c93e5e7 100644
--- a/Source/AuthProxy/Configuration/LogoutCookie.cs
+++ b/Source/AuthProxy/Configuration/LogoutCookie.cs
@@ -14,13 +14,13 @@ namespace Cratis.AuthProxy.Configuration;
public class LogoutCookie
{
///
- /// Gets or sets the exact name of the cookie to delete, e.g. _oauth2_proxy_admin.
+ /// Gets or sets the exact name of the cookie to delete, e.g. _oauth2_proxy_admin.
/// Entries with an empty name are ignored.
///
public string Name { get; set; } = string.Empty;
///
- /// Gets or sets the domain the cookie was scoped to, e.g. .cratis.studio.
+ /// Gets or sets the domain the cookie was scoped to, e.g. .cratis.studio.
/// When set, the deletion is issued for this domain in addition to the request host — required to kill
/// a cookie that was written for a parent domain, since a host-scoped deletion cannot touch it.
/// Leave empty for a cookie scoped to the request host itself.
diff --git a/Source/AuthProxy/Configuration/Management.cs b/Source/AuthProxy/Configuration/Management.cs
index 006bb62..db2613b 100644
--- a/Source/AuthProxy/Configuration/Management.cs
+++ b/Source/AuthProxy/Configuration/Management.cs
@@ -20,13 +20,13 @@ namespace Cratis.AuthProxy.Configuration;
/// The listener is private by intent. It defaults to loopback so it is reachable from a sidecar or a
/// kubelet on the same network namespace and from nowhere else, and the paths it answers exist only on it —
/// they are never added to the anonymous-path policy, the middleware pipeline or the reverse-proxy route
-/// table, so a service that already serves /health keeps serving it.
+/// table, so a service that already serves /health keeps serving it.
///
///
public class Management
{
///
- /// Gets or sets the address the management listener binds. Defaults to 127.0.0.1, which keeps it
+ /// Gets or sets the address the management listener binds. Defaults to 127.0.0.1, which keeps it
/// reachable from within the pod or container and unreachable from the network.
/// Widen it only when the probe genuinely runs elsewhere, and understand that doing so publishes the
/// endpoints to everything that can route to the address.
@@ -43,7 +43,7 @@ public class Management
///
/// Gets or sets the path answering liveness — whether the request loop is servicing requests at all.
- /// It consults nothing, so it stays 200 while storage, the identity provider and every backend
+ /// It consults nothing, so it stays 200 while storage, the identity provider and every backend
/// are unreachable, which is what keeps an orchestrator from restarting a healthy process during an
/// outage of something else.
///
diff --git a/Source/AuthProxy/Configuration/OAuthProvider.cs b/Source/AuthProxy/Configuration/OAuthProvider.cs
index bf71a92..7cde6dd 100644
--- a/Source/AuthProxy/Configuration/OAuthProvider.cs
+++ b/Source/AuthProxy/Configuration/OAuthProvider.cs
@@ -47,8 +47,8 @@ public class OAuthProvider
///
///
/// This supports providers such as GitHub whose regular user-information endpoint can omit a private email and
- /// does not establish verification. The endpoint must return a JSON array with email, verified,
- /// and primary properties. AuthProxy selects exactly one primary, verified address.
+ /// does not establish verification. The endpoint must return a JSON array with email, verified,
+ /// and primary properties. AuthProxy selects exactly one primary, verified address.
///
public string VerifiedEmailEndpoint { get; set; } = string.Empty;
@@ -70,8 +70,8 @@ public class OAuthProvider
///
/// Gets or sets the claim mappings from the user-info JSON response.
- /// Key is the claim type (e.g. ClaimTypes.Name or a custom URN);
- /// value is the JSON field name in the user-info response (e.g. login).
+ /// Key is the claim type (e.g. ClaimTypes.Name or a custom URN);
+ /// value is the JSON field name in the user-info response (e.g. login).
///
public IDictionary ClaimMappings { get; set; } = new Dictionary();
@@ -79,7 +79,7 @@ public class OAuthProvider
/// Gets or sets additional static parameters included in every authorization request to this provider.
///
///
- /// Framework-owned OAuth parameters such as state, redirect_uri, and client_id cannot be configured here.
+ /// Framework-owned OAuth parameters such as state, redirect_uri, and client_id cannot be configured here.
///
public IDictionary AuthorizationParameters { get; set; } = new Dictionary();
diff --git a/Source/AuthProxy/Configuration/OidcProvider.cs b/Source/AuthProxy/Configuration/OidcProvider.cs
index 4b0ce15..31094ae 100644
--- a/Source/AuthProxy/Configuration/OidcProvider.cs
+++ b/Source/AuthProxy/Configuration/OidcProvider.cs
@@ -40,7 +40,7 @@ public class OidcProvider
public string ClientSecret { get; set; } = string.Empty;
///
- /// Gets or sets extra OAuth scopes to request (in addition to openid profile email).
+ /// Gets or sets extra OAuth scopes to request (in addition to openid profile email).
///
public IList Scopes { get; set; } = [];
diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs
index 11ea0aa..37b7998 100644
--- a/Source/AuthProxy/Configuration/Service.cs
+++ b/Source/AuthProxy/Configuration/Service.cs
@@ -40,23 +40,23 @@ public class Service
///
///
/// Without this, every path behind the proxy requires a session: an unauthenticated request is
- /// answered by SelectProviderMiddleware with the provider-selection page — at HTTP 200,
+ /// answered by SelectProviderMiddleware with the provider-selection page — at HTTP 200,
/// so a non-browser caller records success and never retries — and any request that does reach the
/// reverse proxy is refused by the default authorization policy. An application that legitimately
/// serves some paths anonymously (a magic-link landing page, a signed-token report, a public webhook
/// receiver) has no way to express that. Listing those paths here does.
///
/// Each entry is a path prefix, matched case-insensitively on segment boundaries exactly like the
- /// built-in invite / registration / authentication-UI paths: /portal matches /portal and
- /// /portal/anything, but not /portalx. Because it is a prefix, an entry covers
+ /// built-in invite / registration / authentication-UI paths: /portal matches /portal and
+ /// /portal/anything, but not /portalx. Because it is a prefix, an entry covers
/// everything below it, so name the specific leaf path whenever a sibling under the same parent is not
/// public. An entry that is not a plain rooted path of literal segments — blank, unrooted, the bare
- /// /, or carrying a route-template character — is discarded, leaving that path authenticated.
+ /// /, or carrying a route-template character — is discarded, leaving that path authenticated.
/// See for the exact rule.
///
///
/// This does not weaken the identity boundary. Requests still flow through AuthProxy, so
- /// TenancyMiddleware strips inbound x-ms-client-principal* and Tenant-ID headers
+ /// TenancyMiddleware strips inbound x-ms-client-principal* and Tenant-ID headers
/// as it does for every request, and no principal headers are injected for a caller with no session.
/// The application stays responsible for authorizing these paths — this only stops the proxy from
/// demanding a login before the application is ever reached.
@@ -72,21 +72,21 @@ public class Service
/// in, never widen it. Leave unset to require only what the root requires.
///
/// The service a request targets is resolved the way the route table resolves it: the single
- /// configured service when there is only one, otherwise the Service-ID header or the
- /// service query parameter. A request in a multi-service deployment that names no service
+ /// configured service when there is only one, otherwise the Service-ID header or the
+ /// service query parameter. A request in a multi-service deployment that names no service
/// matches no service route either, so only the root requirements apply to it.
///
///
public Authorization? Authorization { get; set; }
///
- /// Gets or sets whether to call the /.cratis/me identity endpoint on this service
+ /// Gets or sets whether to call the /.cratis/me identity endpoint on this service
/// to enrich the identity details cookie. Defaults to when a Backend is configured.
///
public bool? ResolveIdentityDetails { get; set; }
///
- /// Gets or sets what this service's /.cratis/me answer means. Defaults to
+ /// Gets or sets what this service's /.cratis/me answer means. Defaults to
/// , the released behavior.
///
///
@@ -95,7 +95,7 @@ public class Service
/// service can be asked for details it is allowed to fail to supply, or asked for a decision it is not.
///
/// Set this to only for a service that genuinely answers
- /// /.cratis/me with an authorization verdict. Every failure to obtain that verdict then denies
+ /// /.cratis/me with an authorization verdict. Every failure to obtain that verdict then denies
/// the request, which is the point — but it also means an outage of that one service takes the whole
/// proxied surface down with it, deliberately, rather than serving callers whose access nobody could
/// confirm.
@@ -109,7 +109,7 @@ public class Service
public IdentityVerificationMode IdentityVerification { get; set; } = IdentityVerificationMode.BestEffort;
///
- /// Gets or sets how long AuthProxy waits for this service's /.cratis/me answer before treating
+ /// Gets or sets how long AuthProxy waits for this service's /.cratis/me answer before treating
/// the call as failed. Leave unset to let the mode decide — see
/// . Set to zero or a negative value to leave the wait
/// unbounded.
@@ -122,7 +122,7 @@ public class Service
public TimeSpan? IdentityVerificationTimeout { get; set; }
///
- /// Gets how long AuthProxy actually waits for this service's /.cratis/me answer.
+ /// Gets how long AuthProxy actually waits for this service's /.cratis/me answer.
///
///
/// A bound on the wait is a property of fail-closed verification, not of enrichment, so an unstated
diff --git a/Source/AuthProxy/Configuration/ServiceClientCredentials.cs b/Source/AuthProxy/Configuration/ServiceClientCredentials.cs
index 654b06a..2b0e4c2 100644
--- a/Source/AuthProxy/Configuration/ServiceClientCredentials.cs
+++ b/Source/AuthProxy/Configuration/ServiceClientCredentials.cs
@@ -19,7 +19,7 @@ public class ServiceClientCredentials
///
/// Gets or sets the internal verification endpoint that AuthProxy should call with the supplied client credentials.
- /// Relative values are resolved against the service backend BaseUrl; absolute values are used as-is.
+ /// Relative values are resolved against the service backend BaseUrl; absolute values are used as-is.
///
[Required]
public string VerificationPath { get; set; } = "/.cratis/client-credentials/verify";
diff --git a/Source/AuthProxy/Configuration/ServiceEndpoint.cs b/Source/AuthProxy/Configuration/ServiceEndpoint.cs
index bc3e8c6..6b0ff08 100644
--- a/Source/AuthProxy/Configuration/ServiceEndpoint.cs
+++ b/Source/AuthProxy/Configuration/ServiceEndpoint.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Configuration;
public class ServiceEndpoint
{
///
- /// Gets or sets the base URL of the endpoint (e.g. http://my-service:8080/).
+ /// Gets or sets the base URL of the endpoint (e.g. http://my-service:8080/).
///
[Required, Url]
public string BaseUrl { get; set; } = string.Empty;
diff --git a/Source/AuthProxy/Configuration/SignIn.cs b/Source/AuthProxy/Configuration/SignIn.cs
index 4b741b3..a7abb4a 100644
--- a/Source/AuthProxy/Configuration/SignIn.cs
+++ b/Source/AuthProxy/Configuration/SignIn.cs
@@ -19,13 +19,13 @@ public class SignIn
{
///
/// Gets or sets the absolute URL of the application endpoint that records a completed sign-in,
- /// e.g. https://studio.example.com/api/internal/sign-ins.
- /// AuthProxy posts { subject, identityProvider, ipAddress, location, browser, operatingSystem, userAgent }
+ /// e.g. https://studio.example.com/api/internal/sign-ins.
+ /// AuthProxy posts { subject, identityProvider, ipAddress, location, browser, operatingSystem, userAgent }
/// to it. Leave empty to disable sign-in notifications.
///
///
/// With configured this URL must carry no query. The signed route binding is the
- /// RFC 9449 htu, which covers the path only, so a query would travel unsigned — a captured
+ /// RFC 9449 htu, which covers the path only, so a query would travel unsigned — a captured
/// notification could then be replayed against a different query and still verify.
///
public string NotifyUrl { get; set; } = string.Empty;
diff --git a/Source/AuthProxy/Configuration/SignInAttestation.cs b/Source/AuthProxy/Configuration/SignInAttestation.cs
index c255cdd..a8e6243 100644
--- a/Source/AuthProxy/Configuration/SignInAttestation.cs
+++ b/Source/AuthProxy/Configuration/SignInAttestation.cs
@@ -9,20 +9,20 @@ namespace Cratis.AuthProxy.Configuration;
///
///
/// Leave this section unset — the default — and sign-in notifications are posted exactly as they always have
-/// been: an unsigned JSON body with no Authorization header. Nothing about an existing deployment
+/// been: an unsigned JSON body with no Authorization header. Nothing about an existing deployment
/// changes on upgrade.
///
///
/// Set it and AuthProxy signs a short-lived RS256 JWS over each notification and sends it as
-/// Authorization: Bearer. The envelope binds six facts: provenance (iss plus the kid
-/// header), audience (aud), route (htm and htu, per RFC 9449), body (body_hash,
-/// over the exact bytes posted), time (iat, nbf, exp) and replay (a random jti).
+/// Authorization: Bearer. The envelope binds six facts: provenance (iss plus the kid
+/// header), audience (aud), route (htm and htu, per RFC 9449), body (body_hash,
+/// over the exact bytes posted), time (iat, nbf, exp) and replay (a random jti).
/// Once configured, AuthProxy never falls back to an unsigned notification: if the envelope cannot be signed,
/// nothing is posted.
///
///
/// AuthProxy publishes no JWKS document, so the receiving application pins the matching public key by its own
-/// configuration and selects it by the required kid header — the same way the invitation authority
+/// configuration and selects it by the required kid header — the same way the invitation authority
/// consumes .
///
///
@@ -48,7 +48,7 @@ public class SignInAttestation
///
///
/// Keep the previous key during a rotation until every envelope it signed has expired. The receiving
- /// application pins the corresponding public keys and selects one by the required JWS kid header.
+ /// application pins the corresponding public keys and selects one by the required JWS kid header.
///
public IList SigningKeys { get; set; } = [];
diff --git a/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs b/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs
index 5cbf025..1783228 100644
--- a/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs
+++ b/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs
@@ -9,7 +9,7 @@ namespace Cratis.AuthProxy.Configuration;
public class SignInAttestationSigningKey
{
///
- /// Gets or sets the key identifier written to the JWS kid header.
+ /// Gets or sets the key identifier written to the JWS kid header.
///
public string KeyId { get; set; } = string.Empty;
diff --git a/Source/AuthProxy/Configuration/TenantResolution.cs b/Source/AuthProxy/Configuration/TenantResolution.cs
index 876d357..6a6ceaf 100644
--- a/Source/AuthProxy/Configuration/TenantResolution.cs
+++ b/Source/AuthProxy/Configuration/TenantResolution.cs
@@ -15,7 +15,7 @@ public class TenantResolution
///
/// Gets or sets the strategy-specific options (e.g. claim type, regex pattern, fixed tenant ID).
- /// Populated at startup by TenantResolutionOptionsConfigurer which binds the configuration
+ /// Populated at startup by TenantResolutionOptionsConfigurer which binds the configuration
/// sub-section to the concrete typed options class that matches .
///
public object? Options { get; set; }
diff --git a/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs b/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs
index 6abc9d5..4e8a945 100644
--- a/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs
+++ b/Source/AuthProxy/Configuration/TenantSourceIdentifierResolverType.cs
@@ -14,7 +14,7 @@ public enum TenantSourceIdentifierResolverType
/// Resolve the tenant from the request host name, matched against .
Host = 1,
- /// Resolve the tenant from a claim in the x-ms-client-principal, matched against .
+ /// Resolve the tenant from a claim in the x-ms-client-principal, matched against .
Claim = 2,
/// Resolve the tenant from a route segment extracted via a named-group regular expression, matched against .
diff --git a/Source/AuthProxy/Configuration/TenantVerification.cs b/Source/AuthProxy/Configuration/TenantVerification.cs
index 4998071..e915591 100644
--- a/Source/AuthProxy/Configuration/TenantVerification.cs
+++ b/Source/AuthProxy/Configuration/TenantVerification.cs
@@ -10,10 +10,10 @@ public class TenantVerification
{
///
/// Gets or sets the URL template used to verify tenant existence.
- /// Use {tenantId} as a placeholder for the resolved tenant identifier,
- /// e.g. https://platform.example.com/api/tenants/{tenantId}.
- /// An HTTP GET to the resolved URL must return 200 when the tenant exists
- /// and 404 when it does not.
+ /// Use {tenantId} as a placeholder for the resolved tenant identifier,
+ /// e.g. https://platform.example.com/api/tenants/{tenantId}.
+ /// An HTTP GET to the resolved URL must return 200 when the tenant exists
+ /// and 404 when it does not.
///
public string UrlTemplate { get; set; } = string.Empty;
}
diff --git a/Source/AuthProxy/ErrorPages/ErrorPageProvider.cs b/Source/AuthProxy/ErrorPages/ErrorPageProvider.cs
index 49f9799..109042a 100644
--- a/Source/AuthProxy/ErrorPages/ErrorPageProvider.cs
+++ b/Source/AuthProxy/ErrorPages/ErrorPageProvider.cs
@@ -13,7 +13,7 @@ namespace Cratis.AuthProxy.ErrorPages;
/// Page resolution order:
///
/// - The directory configured in (when set and the directory exists).
-/// - A Pages directory co-located with the application's content root.
+/// - A Pages directory co-located with the application's content root.
///
/// If neither location contains the requested page, a minimal inline HTML fallback is written.
///
diff --git a/Source/AuthProxy/ErrorPages/IErrorPageProvider.cs b/Source/AuthProxy/ErrorPages/IErrorPageProvider.cs
index de020b9..7e836f0 100644
--- a/Source/AuthProxy/ErrorPages/IErrorPageProvider.cs
+++ b/Source/AuthProxy/ErrorPages/IErrorPageProvider.cs
@@ -10,11 +10,11 @@ public interface IErrorPageProvider
{
///
/// Writes the content of the named error page into the response.
- /// Sets and Content-Type: text/html before sending.
+ /// Sets and Content-Type: text/html before sending.
/// If the named page does not exist a minimal inline HTML fallback is written instead.
///
/// The current .
- /// The file name of the page, e.g. "404.html" or "invitation-expired.html".
+ /// The file name of the page, e.g. "404.html" or "invitation-expired.html".
/// The HTTP status code to set on the response.
///
/// Optional literal tokens to replace in the page content before it is written — how a page that must
diff --git a/Source/AuthProxy/FrameEmbedding.cs b/Source/AuthProxy/FrameEmbedding.cs
index 4379a5f..3188999 100644
--- a/Source/AuthProxy/FrameEmbedding.cs
+++ b/Source/AuthProxy/FrameEmbedding.cs
@@ -5,7 +5,7 @@ namespace Cratis.AuthProxy;
///
/// Declares whether a page the proxy serves may be embedded in a frame, via
-/// Content-Security-Policy: frame-ancestors (with X-Frame-Options for the deny case).
+/// Content-Security-Policy: frame-ancestors (with X-Frame-Options for the deny case).
///
///
/// The proxy's own pages fall in two classes. Sign-in pages (provider selection, invitation selection)
@@ -38,7 +38,7 @@ public static void Deny(HttpContext context)
/// configured.
///
/// The current .
- /// The configured allowed ancestor origins; "self" names the proxy's own origin.
+ /// The configured allowed ancestor origins; "self" names the proxy's own origin.
public static void Apply(HttpContext context, IEnumerable ancestors)
{
var sources = ancestors
@@ -59,7 +59,7 @@ public static void Apply(HttpContext context, IEnumerable ancestors)
///
/// Resolves the configured ancestors to the concrete origins a framed page may post messages to,
- /// translating "self" to the origin of the current request.
+ /// translating "self" to the origin of the current request.
///
/// The current .
/// The configured allowed ancestor origins.
diff --git a/Source/AuthProxy/HttpContextExtensions.cs b/Source/AuthProxy/HttpContextExtensions.cs
index 1929417..6f0020e 100644
--- a/Source/AuthProxy/HttpContextExtensions.cs
+++ b/Source/AuthProxy/HttpContextExtensions.cs
@@ -29,7 +29,7 @@ public static class HttpContextExtensions
///
/// The value has to be usable, not merely present. Every caller of this asks it in order to relax
/// something — the tenancy refusal, the provider-selection refusal, the identity caches — on the grounds
- /// that an invite exchange is about to run. A bare Cookie: .cratis-invite= answered yes to that
+ /// that an invite exchange is about to run. A bare Cookie: .cratis-invite= answered yes to that
/// while answered no to the exchange itself, so the relaxation
/// happened and the exchange did not, and any caller could ask for it.
///
@@ -68,9 +68,9 @@ public static bool HasPendingRegistration(this HttpContext context) =>
///
/// These two prefixes are reserved () precisely because they are never
/// routed to a backend — the invitation and registration middlewares answer them in full, validating
- /// their own capabilities as they go. Both middlewares run after UseAuthorization, so the
+ /// their own capabilities as they go. Both middlewares run after UseAuthorization, so the
/// reverse proxy's catch-all route would otherwise select an endpoint carrying the default
- /// RequireAuthenticatedUser policy and the request would be challenged before either of them
+ /// RequireAuthenticatedUser policy and the request would be challenged before either of them
/// ever saw it: an invitation link would redirect to provider selection instead of staging the
/// invitation, and the flow could only complete on a second pass once a cookie it never planted
/// happened to exist.
@@ -88,7 +88,7 @@ public static bool IsLogin(this HttpContext context) =>
///
/// Determines whether the request targets a specific provider's login challenge endpoint
- /// (e.g. /.cratis/login/github) — as opposed to the provider-selection page itself.
+ /// (e.g. /.cratis/login/github) — as opposed to the provider-selection page itself.
///
/// The to evaluate.
/// if the request initiates a specific provider's challenge; otherwise .
@@ -140,15 +140,15 @@ public static bool IsAnonymousPath(this HttpContext context, C.AuthProxy config)
/// AuthProxy refuses unauthenticated callers by writing a page — provider selection, tenant selection —
/// and a page has to be delivered with a success status to render. That is the right answer to a person
/// in a browser and the wrong answer to everything else: a webhook or an integration reads the
- /// 200 as delivered and never retries, and a frontend's fetch() passes the conventional
- /// response.ok check and only fails later, on parsing. Callers that are not navigating are
+ /// 200 as delivered and never retries, and a frontend's fetch() passes the conventional
+ /// response.ok check and only fails later, on parsing. Callers that are not navigating are
/// refused with a status instead, so the refusal is visible where it is checked.
///
/// decides when it is present, because it is the only signal that
/// separates a document navigation from a scripted request issued by the very same browser —
- /// fetch() sends Accept: */*, which reads as "HTML will do" and is exactly the
+ /// fetch() sends Accept: */*, which reads as "HTML will do" and is exactly the
/// misclassification to avoid. Only when the header is absent — a client predating fetch metadata —
- /// does Accept decide, and then nothing short of an explicit text/html counts, so a
+ /// does Accept decide, and then nothing short of an explicit text/html counts, so a
/// caller that states nothing is treated as the API caller it almost always is.
///
///
@@ -217,12 +217,12 @@ public static bool TryGetPendingInvitationToken(this HttpContext context, out st
}
///
- /// Determines whether an Accept entry asks for HTML.
+ /// Determines whether an Accept entry asks for HTML.
///
- /// The parsed Accept entry.
+ /// The parsed Accept entry.
/// when the entry asks for HTML; otherwise .
///
- /// Only an explicit text/html counts. The wildcards */* and text/* do not:
+ /// Only an explicit text/html counts. The wildcards */* and text/* do not:
/// they are what a client sends when it will take whatever it is given, and reading them as a request
/// for a page is the misclassification that turns a refusal into a recorded success. A quality of zero
/// is the caller stating outright that HTML is unacceptable, so it is honored rather than matched.
diff --git a/Source/AuthProxy/Identity/ClientPrincipal.cs b/Source/AuthProxy/Identity/ClientPrincipal.cs
index 7ee88af..3bf3e69 100644
--- a/Source/AuthProxy/Identity/ClientPrincipal.cs
+++ b/Source/AuthProxy/Identity/ClientPrincipal.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Identity;
/// Represents the Microsoft Client Principal Data definition used by Azure Static Web Apps,
/// Container Apps and App Service. The ingress builds this from the authenticated
/// and forwards it as
-/// x-ms-client-principal (base64-encoded JSON) on every proxied request.
+/// x-ms-client-principal (base64-encoded JSON) on every proxied request.
///
///
/// See https://learn.microsoft.com/azure/static-web-apps/user-information for the
@@ -21,7 +21,7 @@ public class ClientPrincipal
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
- /// Gets or sets the identity provider (e.g. aad, github).
+ /// Gets or sets the identity provider (e.g. aad, github).
[JsonPropertyName("identityProvider")]
public string IdentityProvider { get; set; } = string.Empty;
@@ -78,7 +78,7 @@ public static bool TryFromBase64(string? base64, out ClientPrincipal? principal)
///
/// Serializes the principal to a base64-encoded JSON string suitable for
- /// the x-ms-client-principal header.
+ /// the x-ms-client-principal header.
///
/// A base64-encoded JSON string representing this principal.
public string ToBase64() => Convert.ToBase64String(ToJsonBytes());
diff --git a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs
index 6278b94..0d7716b 100644
--- a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs
+++ b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs
@@ -98,7 +98,7 @@ public static class ClientPrincipalExtensions
/// The whose identity to forward.
///
/// Every value goes through , so a name a header field cannot
- /// carry is sent as an RFC 8187 ext-value and announced by the
+ /// carry is sent as an RFC 8187 ext-value and announced by the
/// sibling. An already-safe name is untouched and no
/// sibling is written — see for why the encoding is conditional.
///
diff --git a/Source/AuthProxy/Identity/HeaderValue.cs b/Source/AuthProxy/Identity/HeaderValue.cs
index b592521..8e9ca55 100644
--- a/Source/AuthProxy/Identity/HeaderValue.cs
+++ b/Source/AuthProxy/Identity/HeaderValue.cs
@@ -11,15 +11,15 @@ namespace Cratis.AuthProxy.Identity;
/// Turns an identity value into something an HTTP header field can actually carry, and back again.
///
///
-/// A header field value is octets, and .NET refuses to put a character above U+007F on the wire:
-/// the request never reaches the socket, it throws. A person named Søren Wærstad was therefore not
+/// A header field value is octets, and .NET refuses to put a character above U+007F on the wire:
+/// the request never reaches the socket, it throws. A person named Søren Wærstad was therefore not
/// merely garbled downstream — the proxied request failed at the gateway and the identity-endpoint call
/// failed silently, so the application did not work for them at all. Names carrying arbitrary Unicode are
-/// the normal case rather than the exotic one: three of the six claims userDetails is resolved from
+/// the normal case rather than the exotic one: three of the six claims userDetails is resolved from
/// are provider display names.
///
-/// The encoding is the RFC 8187 ext-value —
-/// percent-encoded UTF-8 behind a self-describing UTF-8'' prefix, which covers every code point
+/// The encoding is the RFC 8187 ext-value —
+/// percent-encoded UTF-8 behind a self-describing UTF-8'' prefix, which covers every code point
/// including the astral planes and needs no separate version header to announce itself.
///
///
@@ -29,9 +29,9 @@ namespace Cratis.AuthProxy.Identity;
/// one, see — is encoded.
///
///
-/// Percent-encoding is deliberately conservative: only RFC 8187 attr-char octets survive verbatim,
+/// Percent-encoding is deliberately conservative: only RFC 8187 attr-char octets survive verbatim,
/// so an encoded value can never contain CR, LF or NUL. Combined with the rule that anything already
-/// carrying the UTF-8'' prefix is itself encoded, a consumer that decodes exactly the values the
+/// carrying the UTF-8'' prefix is itself encoded, a consumer that decodes exactly the values the
/// sibling header announces can never obtain a header separator out of an identity value.
///
///
@@ -54,22 +54,22 @@ public static bool IsSafeAscii(string value) =>
value.All(character => character < 0x80 && !char.IsControl(character));
///
- /// Determines whether a value has to be carried as an RFC 8187 ext-value.
+ /// Determines whether a value has to be carried as an RFC 8187 ext-value.
///
/// The value to inspect.
/// when the value cannot travel verbatim; otherwise .
///
/// This is what decides whether the starred sibling header is emitted alongside the plain one, the same
- /// way Content-Disposition pairs filename with filename* (RFC 6266 §4.3).
+ /// way Content-Disposition pairs filename with filename* (RFC 6266 §4.3).
///
/// A value that merely looks encoded counts too, and that half is a security boundary rather
- /// than a nicety. Every character of UTF-8''victim%0D%0AX-Admin:%20true is printable US-ASCII, so
+ /// than a nicety. Every character of UTF-8''victim%0D%0AX-Admin:%20true is printable US-ASCII, so
/// a rule that asked only "is this ASCII?" forwarded it byte for byte with no sibling — and any consumer
/// deciding to decode on the prefix, which is what the published guidance used to show, got a
/// carriage return and a line feed back out of a display name the person chose for themselves. Encoding
/// such a value instead means the sibling's presence is the single unambiguous statement that the plain
- /// header carries an ext-value, and decoding it yields the literal name rather than a header
- /// separator. No realistic name begins with UTF-8'', so byte-for-byte transit is untouched.
+ /// header carries an ext-value, and decoding it yields the literal name rather than a header
+ /// separator. No realistic name begins with UTF-8'', so byte-for-byte transit is untouched.
///
///
public static bool RequiresExtendedValue(string value) =>
@@ -79,7 +79,7 @@ public static bool RequiresExtendedValue(string value) =>
/// Converts a value to the form that goes on the wire.
///
/// The value to convert.
- /// The value itself when it can travel verbatim; otherwise its RFC 8187 ext-value form.
+ /// The value itself when it can travel verbatim; otherwise its RFC 8187 ext-value form.
///
/// Expressed through so the wire form and the sibling header can
/// never disagree about whether a value is encoded — a disagreement is exactly what makes a decoded
@@ -94,7 +94,7 @@ public static bool RequiresExtendedValue(string value) =>
/// The original value, or unchanged when it could not be decoded.
/// when holds the original; otherwise .
///
- /// A value without the UTF-8'' prefix was never encoded, so it decodes to itself. Because
+ /// A value without the UTF-8'' prefix was never encoded, so it decodes to itself. Because
/// encodes anything that carries that prefix, this is the exact inverse
/// of it for every value — including one whose author wrote the prefix themselves.
///
diff --git a/Source/AuthProxy/Identity/IIdentityAuthorizationCache.cs b/Source/AuthProxy/Identity/IIdentityAuthorizationCache.cs
index ccea607..16040c2 100644
--- a/Source/AuthProxy/Identity/IIdentityAuthorizationCache.cs
+++ b/Source/AuthProxy/Identity/IIdentityAuthorizationCache.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy.Identity;
/// Defines the contract for remembering, across requests, that a principal was authorized in a tenant.
///
///
-/// Resolving identity details means calling /.cratis/me on every configured service, and the answer
+/// Resolving identity details means calling /.cratis/me on every configured service, and the answer
/// includes whether the caller is authorized at all. Doing that on every request would put a fan-out of
/// backend calls in front of every proxied request, so the outcome is remembered on the client — which
/// makes the remembered value an authorization decision travelling through the caller's own browser, and
diff --git a/Source/AuthProxy/Identity/IIdentityDetailsResolver.cs b/Source/AuthProxy/Identity/IIdentityDetailsResolver.cs
index 54fa29f..d1dd9f6 100644
--- a/Source/AuthProxy/Identity/IIdentityDetailsResolver.cs
+++ b/Source/AuthProxy/Identity/IIdentityDetailsResolver.cs
@@ -7,14 +7,14 @@ namespace Cratis.AuthProxy.Identity;
///
/// Defines the contract for resolving additional identity details from a microservice's
-/// /.cratis/me endpoint and persisting them as the .cratis-identity cookie.
+/// /.cratis/me endpoint and persisting them as the .cratis-identity cookie.
///
public interface IIdentityDetailsResolver
{
///
- /// Calls /.cratis/me on every configured microservice that exposes an
+ /// Calls /.cratis/me on every configured microservice that exposes an
/// identity details endpoint, merges the results and writes (or refreshes) the
- /// .cratis-identity response cookie as a full .
+ /// .cratis-identity response cookie as a full .
///
/// The current .
/// The representing the authenticated user.
diff --git a/Source/AuthProxy/Identity/IdentityAuthorizationCache.cs b/Source/AuthProxy/Identity/IdentityAuthorizationCache.cs
index 1e10d20..540ebf8 100644
--- a/Source/AuthProxy/Identity/IdentityAuthorizationCache.cs
+++ b/Source/AuthProxy/Identity/IdentityAuthorizationCache.cs
@@ -11,11 +11,11 @@ namespace Cratis.AuthProxy.Identity;
/// Remembers an authorization outcome in a cookie the caller cannot forge.
///
///
-/// The readable .cratis-identity cookie exists so a frontend can render the signed-in user without
+/// The readable .cratis-identity cookie exists so a frontend can render the signed-in user without
/// a round-trip, which is why it is written non-HTTP-only and in plain base64. That makes it useless as
/// evidence: any script on a proxied origin, and any non-browser client at all, can write whatever it
/// likes into it. This writes the decision to a separate HTTP-only cookie sealed with ASP.NET
-/// data protection, so the value that skips the /.cratis/me authorization call is one only
+/// data protection, so the value that skips the /.cratis/me authorization call is one only
/// AuthProxy can have produced.
///
/// The versioned structured payload names the principal's account binding and the tenant it was issued for,
@@ -25,7 +25,7 @@ namespace Cratis.AuthProxy.Identity;
/// bearer value, so without the comparison a caller could keep a record issued for one tenant and present
/// it while acting in another, or a record from an old session could authorize whoever holds the browser
/// next. The expiry is carried inside the sealed payload rather than left to the cookie's
-/// Max-Age, because Max-Age is a request to the browser and a client that declines to honor
+/// Max-Age, because Max-Age is a request to the browser and a client that declines to honor
/// it would otherwise hold an authorization that never lapses.
///
///
diff --git a/Source/AuthProxy/Identity/IdentityDetailsResolver.cs b/Source/AuthProxy/Identity/IdentityDetailsResolver.cs
index 0f60b69..583a921 100644
--- a/Source/AuthProxy/Identity/IdentityDetailsResolver.cs
+++ b/Source/AuthProxy/Identity/IdentityDetailsResolver.cs
@@ -13,9 +13,9 @@
namespace Cratis.AuthProxy.Identity;
///
-/// Calls every service's /.cratis/me endpoint to retrieve application-specific
+/// Calls every service's /.cratis/me endpoint to retrieve application-specific
/// identity details, merges the JSON results, converts them to an
-/// and stores it in the .cratis-identity response cookie as a base64-encoded JSON string.
+/// and stores it in the .cratis-identity response cookie as a base64-encoded JSON string.
///
/// The auth proxy configuration.
/// The HTTP client factory.
@@ -26,7 +26,7 @@ namespace Cratis.AuthProxy.Identity;
///
/// What each service's answer is worth is a per-service setting — see
/// . Under only
-/// an HTTP 403 denies, which is exactly what the released proxy denied on and nothing more. Under
+/// an HTTP 403 denies, which is exactly what the released proxy denied on and nothing more. Under
/// only an explicit positive admits, and every other
/// outcome denies and erases what an earlier positive left behind.
///
@@ -202,9 +202,9 @@ public async Task Resolve(HttpContext context, ClientPri
/// when the request may continue; otherwise .
///
/// denies on the one trigger the released proxy
- /// denied on — an HTTP 403 — and on nothing else. The released call never read a verdict out of
- /// the body at all: it returned details and forwarded the caller whatever the body said about
- /// isAuthorized. Promoting a body-level negative to a denial here would change what the
+ /// denied on — an HTTP 403 — and on nothing else. The released call never read a verdict out of
+ /// the body at all: it returned details and forwarded the caller whatever the body said about
+ /// isAuthorized. Promoting a body-level negative to a denial here would change what the
/// default mode does to services that never opted in, and would do it silently, to the exact
/// response shape the documented envelope tells them to write.
/// is where a body-level verdict becomes a decision.
@@ -272,7 +272,7 @@ IdentityProviderResult BuildAuthorizedResult(ClientPrincipal principal, object?
/// Refusing without erasing would be nearly useless: an earlier success leaves a sealed record, a
/// readable cookie and an in-memory result behind, and any one of them lets the very next request skip
/// the question that was just answered no. All three go together, and they go on every refusal — the
- /// released code cleared none of them, and Clear had no caller at all.
+ /// released code cleared none of them, and Clear had no caller at all.
///
IdentityProviderResult Deny(HttpContext context, IdentityAccountTenantKey? cacheKey, string serviceName, IdentityVerificationReason reason)
{
diff --git a/Source/AuthProxy/Identity/IdentityEndpointCaller.cs b/Source/AuthProxy/Identity/IdentityEndpointCaller.cs
index dda1129..cf2e86e 100644
--- a/Source/AuthProxy/Identity/IdentityEndpointCaller.cs
+++ b/Source/AuthProxy/Identity/IdentityEndpointCaller.cs
@@ -7,7 +7,7 @@
namespace Cratis.AuthProxy.Identity;
///
-/// Calls one service's /.cratis/me endpoint and reports what it established.
+/// Calls one service's /.cratis/me endpoint and reports what it established.
///
/// The HTTP client factory.
///
diff --git a/Source/AuthProxy/Identity/IdentityMiddleware.cs b/Source/AuthProxy/Identity/IdentityMiddleware.cs
index 5d477d3..3e413c5 100644
--- a/Source/AuthProxy/Identity/IdentityMiddleware.cs
+++ b/Source/AuthProxy/Identity/IdentityMiddleware.cs
@@ -10,8 +10,8 @@ namespace Cratis.AuthProxy.Identity;
///
/// Middleware that resolves identity details for every authenticated request:
-/// enriches the principal, calls /.cratis/me on configured services, and
-/// writes the result to the .cratis-identity response cookie.
+/// enriches the principal, calls /.cratis/me on configured services, and
+/// writes the result to the .cratis-identity response cookie.
///
/// The next middleware in the pipeline.
/// The auth proxy configuration monitor.
diff --git a/Source/AuthProxy/Identity/IdentityVerificationConfigurationValidator.cs b/Source/AuthProxy/Identity/IdentityVerificationConfigurationValidator.cs
index af995c1..bd51655 100644
--- a/Source/AuthProxy/Identity/IdentityVerificationConfigurationValidator.cs
+++ b/Source/AuthProxy/Identity/IdentityVerificationConfigurationValidator.cs
@@ -21,7 +21,7 @@ namespace Cratis.AuthProxy.Identity;
/// choose between refusing every caller of a deployment that is misconfigured rather than under attack, and
/// admitting them, and both answers are wrong for a condition that is a configuration mistake rather than a
/// property of the request. naming a strategy — even
-/// Specified, for a single-tenant deployment — is what clears it.
+/// Specified, for a single-tenant deployment — is what clears it.
///
///
public class IdentityVerificationConfigurationValidator : IValidateOptions
diff --git a/Source/AuthProxy/Identity/IdentityVerificationOutcome.cs b/Source/AuthProxy/Identity/IdentityVerificationOutcome.cs
index 79ded85..f5d1194 100644
--- a/Source/AuthProxy/Identity/IdentityVerificationOutcome.cs
+++ b/Source/AuthProxy/Identity/IdentityVerificationOutcome.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Identity;
///
-/// Represents what one service's /.cratis/me call established, and the details it supplied.
+/// Represents what one service's /.cratis/me call established, and the details it supplied.
///
/// What the service established about the caller.
/// The bounded code explaining how that status was reached.
diff --git a/Source/AuthProxy/Identity/IdentityVerificationReason.cs b/Source/AuthProxy/Identity/IdentityVerificationReason.cs
index 8eca25b..5a54d44 100644
--- a/Source/AuthProxy/Identity/IdentityVerificationReason.cs
+++ b/Source/AuthProxy/Identity/IdentityVerificationReason.cs
@@ -21,7 +21,7 @@ public enum IdentityVerificationReason
Verified = 0,
///
- /// The service answered 403 Forbidden, refusing the caller outright.
+ /// The service answered 403 Forbidden, refusing the caller outright.
///
Forbidden = 1,
diff --git a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs
index 9aed16a..8a9a3d6 100644
--- a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs
+++ b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs
@@ -7,12 +7,12 @@ namespace Cratis.AuthProxy.Identity;
///
/// A YARP that injects the three Microsoft Identity Platform
-/// headers (x-ms-client-principal, x-ms-client-principal-id,
-/// x-ms-client-principal-name) and the Tenant-ID header into every
+/// headers (x-ms-client-principal, x-ms-client-principal-id,
+/// x-ms-client-principal-name) and the Tenant-ID header into every
/// proxied request, based on the authenticated user and the resolved tenant.
///
///
-/// Every inbound copy is removed first — including the x-ms-client-principal-name* sibling, which a
+/// Every inbound copy is removed first — including the x-ms-client-principal-name* sibling, which a
/// caller could otherwise use to tell a backend a different name than the one the proxy vouched for.
///
public class InjectIdentityHeadersTransform : RequestTransform
diff --git a/Source/AuthProxy/Ingress/TrustedProxyAddress.cs b/Source/AuthProxy/Ingress/TrustedProxyAddress.cs
index a7aa2aa..d5dcc9a 100644
--- a/Source/AuthProxy/Ingress/TrustedProxyAddress.cs
+++ b/Source/AuthProxy/Ingress/TrustedProxyAddress.cs
@@ -26,7 +26,7 @@ public static class TrustedProxyAddress
///
/// A bare address resolves to the single-address network containing only it, so an entry is always a
/// range and matching is one operation rather than two. A CIDR range is normalized to its network
- /// address, so 10.0.0.1/8 and 10.0.0.0/8 name the same range — the conventional reading,
+ /// address, so 10.0.0.1/8 and 10.0.0.0/8 name the same range — the conventional reading,
/// and the one every other tool an operator copies a range from applies.
///
public static IPNetwork? Resolve(string value)
diff --git a/Source/AuthProxy/Ingress/TrustedProxyPolicy.cs b/Source/AuthProxy/Ingress/TrustedProxyPolicy.cs
index cfabdba..43e1144 100644
--- a/Source/AuthProxy/Ingress/TrustedProxyPolicy.cs
+++ b/Source/AuthProxy/Ingress/TrustedProxyPolicy.cs
@@ -82,7 +82,7 @@ public void ApplyTo(ForwardedHeadersOptions options)
/// when the address is inside a declared network; otherwise .
///
/// A dual-stack socket reports an IPv4 peer as its IPv6-mapped form, so an operator who declared
- /// 10.0.0.0/8 would otherwise see none of their own traffic match. The forwarded-headers
+ /// 10.0.0.0/8 would otherwise see none of their own traffic match. The forwarded-headers
/// middleware unmaps for exactly the same reason.
///
bool Matches(IPAddress peer) =>
diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs
index 8e7df7e..2140dfb 100644
--- a/Source/AuthProxy/IngressExtensions.cs
+++ b/Source/AuthProxy/IngressExtensions.cs
@@ -117,7 +117,7 @@ public static WebApplicationBuilder AddIngressConfiguration(this WebApplicationB
///
/// Configures the middleware pipeline: forwarded headers, static files, authentication,
/// authorization, tenancy, invites, and the reverse proxy.
- /// Also maps the /.cratis/providers endpoint and the per-provider login endpoints.
+ /// Also maps the /.cratis/providers endpoint and the per-provider login endpoints.
///
/// The to configure.
/// The same for chaining.
diff --git a/Source/AuthProxy/Invites/InvitationCompletionFailureReason.cs b/Source/AuthProxy/Invites/InvitationCompletionFailureReason.cs
new file mode 100644
index 0000000..7021736
--- /dev/null
+++ b/Source/AuthProxy/Invites/InvitationCompletionFailureReason.cs
@@ -0,0 +1,84 @@
+// 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;
+
+///
+/// Names the guard that stopped an attested invitation completion before the downstream exchange call, for
+/// operator diagnosis. Every member names a stage or predicate only - never a token, email, subject,
+/// capability hash, transaction, challenge, or other raw claim value.
+///
+internal enum InvitationCompletionFailureReason
+{
+ /// No failure occurred.
+ None = 0,
+
+ /// The attested protocol's required collaborators are not fully configured for this request.
+ ProtocolMisconfigured = 1,
+
+ /// The invitation capability exceeds the maximum accepted length.
+ CapabilityTokenTooLong = 2,
+
+ /// The protected invitation-entry state cookie was not presented or did not unprotect.
+ EntryStateUnprotectFailed = 3,
+
+ /// The protected invitation-entry state has expired.
+ EntryStateExpired = 4,
+
+ /// The presented capability does not hash to the one the entry state was staged for.
+ CapabilityHashMismatch = 5,
+
+ /// The capability's invitation identifier claim did not occur exactly once with an acceptable value.
+ InvitationIdClaimInvalid = 6,
+
+ /// The capability's invitation identifier does not match the staged entry state.
+ InvitationIdMismatch = 7,
+
+ /// No tenant claim is configured for invitation completion.
+ TenantClaimNotConfigured = 8,
+
+ /// The capability's tenant claim did not occur exactly once with an acceptable value.
+ TenantClaimInvalid = 9,
+
+ /// The capability's tenant claim does not match the staged entry state.
+ TenantIdMismatch = 10,
+
+ /// The capability's recipient mode (email-targeted or identity-bound) could not be resolved.
+ RecipientModeInvalid = 11,
+
+ /// The tenant resolved for the current request does not match the capability's tenant.
+ RequestTenantMismatch = 12,
+
+ /// The evidence session backing this callback or request was not established.
+ SessionNotEstablished = 13,
+
+ /// The session's authentication properties do not carry the exact staged challenge binding.
+ ChallengeBindingMismatch = 14,
+
+ /// The authenticated request carried no principal.
+ PrincipalMissing = 15,
+
+ /// The provider that authenticated the principal has no canonical identity configuration.
+ CanonicalIdentityNotConfigured = 16,
+
+ /// Canonical identity resolution failed against malformed, missing, duplicate, or conflicting claims.
+ CanonicalIdentityResolutionFailed = 17,
+
+ /// Zero or more than one configured provider matches the resolved canonical provider key.
+ ProviderConfigurationAmbiguous = 18,
+
+ /// The provider-supplied authentication assurance or authentication-time evidence is unavailable.
+ AssuranceEvidenceUnavailable = 19,
+
+ /// The provider has not been enabled for email-targeted invitation completion.
+ EmailCompletionDisabledForProvider = 20,
+
+ /// The provider has not been enabled for identity-bound invitation completion.
+ IdentityBindingCompletionDisabled = 21,
+
+ /// The authenticated provider does not match the capability's bound identity provider.
+ IdentityBindingProviderMismatch = 22,
+
+ /// The completion attestation could not be issued.
+ AttestationIssuanceFailed = 23,
+}
diff --git a/Source/AuthProxy/Invites/InviteCompletion.cs b/Source/AuthProxy/Invites/InviteCompletion.cs
index a226d65..a49dc2d 100644
--- a/Source/AuthProxy/Invites/InviteCompletion.cs
+++ b/Source/AuthProxy/Invites/InviteCompletion.cs
@@ -153,12 +153,12 @@ internal bool ResolvedTenantMatchesWhenPresent(HttpContext context, string tenan
///
/// The authenticated principal completing the invitation.
///
- /// The value of the provider's email_verified claim when present; otherwise .
+ /// The value of the provider's email_verified claim when present; otherwise .
///
/// The authenticated email, or an empty string when none is available.
///
- /// preferred_username is a username, not an address — for a GitHub OAuth provider it is conventionally
- /// mapped from login. It is read only when it actually holds an address, which several OIDC providers
+ /// preferred_username is a username, not an address — for a GitHub OAuth provider it is conventionally
+ /// mapped from login. It is read only when it actually holds an address, which several OIDC providers
/// put there (Entra's is the user principal name). Returning a login name here would make a provider that
/// supplied no address at all indistinguishable from one that supplied somebody else's.
///
@@ -195,50 +195,48 @@ static bool TryGetSingleExactClaim(ClaimsPrincipal principal, string claimType,
static bool FixedTimeEquals(string expected, string actual) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(actual));
- async Task CompleteAttestedInvitation(HttpContext context, string inviteToken, Func> sessionFactory)
+ /// Logs and maps a failed verified-identity resolution to its outward exchange outcome.
+ /// The logger to record the failure on.
+ /// The failed .
+ /// The to answer the caller with.
+ static InviteExchangeResult LogAndMapIdentityFailure(ILogger logger, VerifiedIdentityResolution identityResolution)
{
- var invite = config.CurrentValue.Invite;
- if (invite is null
- || canonicalIdentityResolver is null
- || attestationIssuer is null
- || entryStateProtector is null
- || inviteToken.Length > MaximumAttestedInvitationTokenLength
- || !context.Request.Cookies.TryGetValue(Cookies.InvitationEntryState, out var protectedState)
- || !entryStateProtector.TryUnprotect(protectedState, out var entryState)
- || entryState.ExpiresAt <= DateTimeOffset.UtcNow
- || !FixedTimeEquals(entryState.CapabilityHash, InvitationAuthenticationState.ComputeCapabilityHash(inviteToken))
- || !TryGetSingleTokenClaim(inviteToken, JwtRegisteredClaimNames.Jti, out var invitationId)
- || !FixedTimeEquals(entryState.InvitationId, invitationId)
- || string.IsNullOrWhiteSpace(invite.TenantClaim)
- || !TryGetSingleTokenClaim(inviteToken, invite.TenantClaim, out var tenantId)
- || !FixedTimeEquals(entryState.TenantId, tenantId)
- || !InviteMiddleware.TryResolveRecipientMode(inviteToken, invite.EmailClaim, out var recipientProviderKey)
- || !ResolvedTenantMatchesWhenPresent(context, tenantId))
+ if (identityResolution.EmailOutcome == InviteExchangeResult.EmailMismatch)
{
- return InviteExchangeResult.Failed;
+ logger.InviteEmailMismatch();
+ return InviteExchangeResult.EmailMismatch;
}
- var session = await sessionFactory();
- if (!session.Succeeded
- || !InvitationAuthenticationState.Matches(entryState, session.Properties)
- || !TryResolveVerifiedIdentity(session, recipientProviderKey, out var identity)
- || (string.IsNullOrEmpty(recipientProviderKey)
- && EvaluateInvitedEmailBinding(inviteToken, identity.Email!, true) != InviteExchangeResult.Success)
- || !attestationIssuer.TryIssueComplete(entryState, identity, out var attestation))
+ if (identityResolution.EmailOutcome == InviteExchangeResult.EmailUnavailable)
{
- return InviteExchangeResult.Failed;
+ logger.InviteEmailUnavailable();
+ return InviteExchangeResult.EmailUnavailable;
}
+ logger.AttestedInvitationCompletionFailed(identityResolution.Reason);
+ return InviteExchangeResult.Failed;
+ }
+
+ /// Calls the invitation exchange endpoint with a freshly issued attestation.
+ /// The HTTP client factory used for the exchange call.
+ /// The logger to record the outcome on.
+ /// The current , consulted only for its cancellation token.
+ /// The succeeded naming the exchange URL and transaction.
+ /// The signed attestation bearer token.
+ /// The outcome of the exchange call.
+ static async Task SendAttestedExchangeRequest(IHttpClientFactory httpClientFactory, ILogger logger, HttpContext context, EntryStateResolution entryResolution, string attestation)
+ {
using var client = httpClientFactory.CreateClient();
- using var request = new HttpRequestMessage(HttpMethod.Post, invite.ExchangeUrl);
+ using var request = new HttpRequestMessage(HttpMethod.Post, entryResolution.Invite.ExchangeUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", attestation);
- request.Content = JsonContent.Create(new InvitationCompleteRequest(entryState.InvitationTransaction));
+ request.Content = JsonContent.Create(new InvitationCompleteRequest(entryResolution.EntryState.InvitationTransaction));
try
{
using var response = await client.SendAsync(request, context.RequestAborted);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
+ logger.InviteSubjectAlreadyExists();
return InviteExchangeResult.DuplicateSubject;
}
@@ -253,71 +251,285 @@ async Task CompleteAttestedInvitation(HttpContext context,
}
catch (Exception exception)
{
- logger.FailedToCallInviteExchangeEndpoint(exception, invite.ExchangeUrl);
+ logger.FailedToCallInviteExchangeEndpoint(exception, entryResolution.Invite.ExchangeUrl);
+ return InviteExchangeResult.Failed;
+ }
+ }
+
+ ///
+ /// Resolves an email-targeted recipient's verified identity, enforcing the strict single-explicit-true
+ /// email_verified claim requirement described on .
+ ///
+ /// The authenticated principal.
+ /// The single configured canonical identity provider matching the resolved provider key.
+ /// The resolved canonical federated identity.
+ /// The invited email captured with the entry state, before the session was awaited.
+ /// The provider-supplied authentication assurance evidence.
+ /// The session's authentication-time evidence.
+ /// The resolved .
+ ///
+ /// Every requirement here is unconditional: the recipient is the one captured at entry, the provider must
+ /// supply exactly one well-formed address, and that address must be explicitly verified. Nothing is read
+ /// from the configuration monitor at this point - the method is static so it cannot be - so a reload
+ /// between the entry stage and here can neither widen the binding into an unbound invitation nor rename
+ /// the claim a different recipient would be read from. The completion matches the captured recipient or
+ /// fails closed.
+ ///
+ static VerifiedIdentityResolution ResolveEmailTargetedIdentity(
+ ClaimsPrincipal principal, C.CanonicalIdentity provider, CanonicalFederatedIdentity canonical, string invitedEmail, string assurance, DateTimeOffset authenticatedAt)
+ {
+ if (!provider.InvitationCompletionEnabled)
+ {
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.EmailCompletionDisabledForProvider);
+ }
+
+ if (!InviteMiddleware.IsAnEmailAddress(invitedEmail))
+ {
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.RecipientModeInvalid);
+ }
+
+ if (!TryGetSingleExactClaim(principal, provider.EmailClaimType, out var email)
+ || !InviteMiddleware.IsAnEmailAddress(email))
+ {
+ return VerifiedIdentityResolution.EmailUnavailable;
+ }
+
+ if (!TryGetSingleExactClaim(principal, provider.EmailVerifiedClaimType, out var rawEmailVerified)
+ || !bool.TryParse(rawEmailVerified, out var emailVerified)
+ || !emailVerified
+ || !string.Equals(invitedEmail, email, StringComparison.OrdinalIgnoreCase))
+ {
+ return VerifiedIdentityResolution.EmailMismatch;
+ }
+
+ return VerifiedIdentityResolution.Success(new InvitationVerifiedIdentity(
+ canonical.ProviderKey, canonical.NormalizedIssuer, canonical.Subject, email, assurance, authenticatedAt));
+ }
+
+ async Task CompleteAttestedInvitation(HttpContext context, string inviteToken, Func> sessionFactory)
+ {
+ var entryResolution = ResolveEntryState(context, inviteToken);
+ if (!entryResolution.Succeeded)
+ {
+ logger.AttestedInvitationCompletionFailed(entryResolution.Reason);
+ return InviteExchangeResult.Failed;
+ }
+
+ var session = await sessionFactory();
+ if (!session.Succeeded)
+ {
+ logger.AttestedInvitationCompletionFailed(InvitationCompletionFailureReason.SessionNotEstablished);
+ return InviteExchangeResult.Failed;
+ }
+
+ if (!InvitationAuthenticationState.Matches(entryResolution.EntryState, session.Properties))
+ {
+ logger.AttestedInvitationCompletionFailed(InvitationCompletionFailureReason.ChallengeBindingMismatch);
return InviteExchangeResult.Failed;
}
+
+ var identityResolution = ResolveVerifiedIdentity(session, entryResolution);
+ if (!identityResolution.Succeeded)
+ {
+ return LogAndMapIdentityFailure(logger, identityResolution);
+ }
+
+ if (!attestationIssuer!.TryIssueComplete(entryResolution.EntryState, identityResolution.Identity, out var attestation))
+ {
+ logger.AttestedInvitationCompletionFailed(InvitationCompletionFailureReason.AttestationIssuanceFailed);
+ return InviteExchangeResult.Failed;
+ }
+
+ return await SendAttestedExchangeRequest(httpClientFactory, logger, context, entryResolution, attestation);
+ }
+
+ ///
+ /// Same guard order as the original compound condition; split into named stages only so each one can
+ /// report its own bounded reason instead of a single collapsed boolean.
+ ///
+ /// The current .
+ /// The invitation capability presented on the request.
+ /// The resolved .
+ EntryStateResolution ResolveEntryState(HttpContext context, string inviteToken)
+ {
+ var invite = config.CurrentValue.Invite;
+ if (invite is null || canonicalIdentityResolver is null || attestationIssuer is null || entryStateProtector is null)
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.ProtocolMisconfigured);
+ }
+
+ if (inviteToken.Length > MaximumAttestedInvitationTokenLength)
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.CapabilityTokenTooLong);
+ }
+
+ if (!context.Request.Cookies.TryGetValue(Cookies.InvitationEntryState, out var protectedState)
+ || !entryStateProtector.TryUnprotect(protectedState, out var entryState))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.EntryStateUnprotectFailed);
+ }
+
+ if (entryState.ExpiresAt <= DateTimeOffset.UtcNow)
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.EntryStateExpired);
+ }
+
+ if (!FixedTimeEquals(entryState.CapabilityHash, InvitationAuthenticationState.ComputeCapabilityHash(inviteToken)))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.CapabilityHashMismatch);
+ }
+
+ if (!TryGetSingleTokenClaim(inviteToken, JwtRegisteredClaimNames.Jti, out var invitationId))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.InvitationIdClaimInvalid);
+ }
+
+ if (!FixedTimeEquals(entryState.InvitationId, invitationId))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.InvitationIdMismatch);
+ }
+
+ return ResolveTenantScopedEntryState(context, invite, entryState, inviteToken);
}
- bool TryResolveVerifiedIdentity(
- InvitationCompletionSession session,
- string recipientProviderKey,
- out InvitationVerifiedIdentity identity)
+ ///
+ /// Continues the entry-state guard chain once capability and invitation-id evidence have checked out,
+ /// resolving the tenant-scoped facts and the recipient mode.
+ ///
+ /// The current .
+ /// The resolved invite configuration.
+ /// The unprotected invitation-entry state.
+ /// The invitation capability presented on the request.
+ /// The resolved .
+ EntryStateResolution ResolveTenantScopedEntryState(HttpContext context, C.Invite invite, InvitationEntryState entryState, string inviteToken)
+ {
+ if (string.IsNullOrWhiteSpace(invite.TenantClaim))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.TenantClaimNotConfigured);
+ }
+
+ if (!TryGetSingleTokenClaim(inviteToken, invite.TenantClaim, out var tenantId))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.TenantClaimInvalid);
+ }
+
+ if (!FixedTimeEquals(entryState.TenantId, tenantId))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.TenantIdMismatch);
+ }
+
+ if (!InviteMiddleware.TryResolveRecipientMode(inviteToken, invite.EmailClaim, out var recipientProviderKey))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.RecipientModeInvalid);
+ }
+
+ var invitedEmail = string.Empty;
+ if (string.IsNullOrEmpty(recipientProviderKey)
+ && (!TryGetSingleTokenClaim(inviteToken, invite.EmailClaim, out invitedEmail)
+ || !InviteMiddleware.IsAnEmailAddress(invitedEmail)))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.RecipientModeInvalid);
+ }
+
+ if (!ResolvedTenantMatchesWhenPresent(context, tenantId))
+ {
+ return EntryStateResolution.Failure(InvitationCompletionFailureReason.RequestTenantMismatch);
+ }
+
+ return EntryStateResolution.Success(invite, entryState, recipientProviderKey, invitedEmail);
+ }
+
+ ///
+ /// Resolves the verified identity an attested completion may be issued for, against the recipient facts
+ /// captured by before the session was awaited.
+ ///
+ /// The completion session carrying the authenticated principal.
+ /// The succeeded entry-state resolution carrying the captured recipient facts.
+ /// The resolved .
+ ///
+ /// Unlike the legacy protocol's call below, a missing/duplicated/
+ /// unparseable email_verified claim is never treated as acceptable here - only a single claim parsing to
+ /// exactly true counts as verified; anything else is evaluated as unverified, exactly like an explicit "false".
+ ///
+ VerifiedIdentityResolution ResolveVerifiedIdentity(InvitationCompletionSession session, EntryStateResolution entryResolution)
{
- identity = default!;
var principal = session.Principal;
if (principal is null)
{
- return false;
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.PrincipalMissing);
}
- var resolution = canonicalIdentityResolver!.Resolve(principal, principal.Identity?.AuthenticationType);
- if (!resolution.IsConfigured || !resolution.Succeeded || resolution.Identity is null)
+ // The scheme is asserted, never read off the principal: by this point in both call sites - a
+ // re-authenticated cookie session, or a ticket about to be signed into that same cookie scheme -
+ // the principal is already the once-canonicalized cookie identity, not a fresh provider callback.
+ // A protocol-dependent artifact like ClaimsIdentity.AuthenticationType (OIDC's default identity
+ // carries "AuthenticationTypes.Federation", never the provider's scheme name or "Cookies") must
+ // never stand in for that server-owned fact, upstream Cratis/AuthProxy#122.
+ var resolution = canonicalIdentityResolver!.Resolve(principal, CookieAuthenticationDefaults.AuthenticationScheme);
+ if (!resolution.IsConfigured)
{
- return false;
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.CanonicalIdentityNotConfigured);
+ }
+
+ if (!resolution.Succeeded || resolution.Identity is null)
+ {
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.CanonicalIdentityResolutionFailed);
}
- var canonical = resolution.Identity;
- var providers = authConfig.CurrentValue.OidcProviders
+ return ResolveVerifiedIdentityForCanonical(principal, session, entryResolution, resolution.Identity);
+ }
+
+ ///
+ /// Continues verified-identity resolution once the canonical identity itself has resolved: locates the
+ /// single configured provider matching its provider key, then resolves the recipient-mode-specific facts.
+ ///
+ /// The authenticated principal.
+ /// The completion session carrying the authentication-time evidence.
+ /// The succeeded entry-state resolution carrying the captured recipient facts.
+ /// The resolved canonical federated identity.
+ /// The resolved .
+ VerifiedIdentityResolution ResolveVerifiedIdentityForCanonical(
+ ClaimsPrincipal principal, InvitationCompletionSession session, EntryStateResolution entryResolution, CanonicalFederatedIdentity canonical)
+ {
+ // One snapshot answers both provider questions: two reads of the monitor could disagree and admit a
+ // provider that never appeared in a single configuration state.
+ var authentication = authConfig.CurrentValue;
+ var providers = authentication.OidcProviders
.Where(_ => string.Equals(_.CanonicalIdentity?.ProviderKey, canonical.ProviderKey, StringComparison.Ordinal))
.Select(_ => _.CanonicalIdentity!)
- .Concat(authConfig.CurrentValue.OAuthProviders
+ .Concat(authentication.OAuthProviders
.Where(_ => string.Equals(_.CanonicalIdentity?.ProviderKey, canonical.ProviderKey, StringComparison.Ordinal))
.Select(_ => _.CanonicalIdentity!))
.ToArray();
- if (providers.Length != 1
- || !TryGetSingleExactClaim(principal, providers[0].AssuranceClaimType, out var assurance)
+ if (providers.Length != 1)
+ {
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.ProviderConfigurationAmbiguous);
+ }
+
+ var provider = providers[0];
+ if (!TryGetSingleExactClaim(principal, provider.AssuranceClaimType, out var assurance)
|| session.AuthenticatedAt is not { } authenticatedAt)
{
- return false;
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.AssuranceEvidenceUnavailable);
}
- string? email = null;
- if (string.IsNullOrEmpty(recipientProviderKey))
+ if (string.IsNullOrEmpty(entryResolution.RecipientProviderKey))
{
- if (!providers[0].InvitationCompletionEnabled
- || !TryGetSingleExactClaim(principal, providers[0].EmailClaimType, out email)
- || !InviteMiddleware.IsAnEmailAddress(email)
- || !TryGetSingleExactClaim(principal, providers[0].EmailVerifiedClaimType, out var emailVerified)
- || !bool.TryParse(emailVerified, out var verified)
- || !verified)
- {
- return false;
- }
+ return ResolveEmailTargetedIdentity(principal, provider, canonical, entryResolution.InvitedEmail, assurance, authenticatedAt);
}
- else if (!providers[0].InvitationIdentityBindingCompletionEnabled
- || !FixedTimeEquals(canonical.ProviderKey, recipientProviderKey))
+
+ if (!provider.InvitationIdentityBindingCompletionEnabled)
{
- return false;
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.IdentityBindingCompletionDisabled);
}
- identity = new InvitationVerifiedIdentity(
- canonical.ProviderKey,
- canonical.NormalizedIssuer,
- canonical.Subject,
- email,
- assurance,
- authenticatedAt);
- return true;
+ if (!FixedTimeEquals(canonical.ProviderKey, entryResolution.RecipientProviderKey))
+ {
+ return VerifiedIdentityResolution.Failure(InvitationCompletionFailureReason.IdentityBindingProviderMismatch);
+ }
+
+ return VerifiedIdentityResolution.Success(new InvitationVerifiedIdentity(
+ canonical.ProviderKey, canonical.NormalizedIssuer, canonical.Subject, null, assurance, authenticatedAt));
}
async Task ExchangeInvite(string inviteToken, ClaimsPrincipal principal)
@@ -421,7 +633,7 @@ async Task ExchangeInvite(string inviteToken, ClaimsPrinci
/// The validated invite token.
/// The authenticating account's email.
///
- /// The provider's email_verified value: , , or
+ /// The provider's email_verified value: , , or
/// when the provider supplies no independent verification claim.
///
///
@@ -546,4 +758,51 @@ string BuildLobbyRedirectUrlWithInvitationId(string lobbyUrl, string inviteToken
}
bool IsAttestedProtocolEnabled() => config.CurrentValue.Invite?.Attestation is not null;
+
+ /// An attested invitation's pre-HTTP entry-state resolution.
+ /// when the entry state resolved.
+ /// The resolved invite configuration.
+ /// The unprotected invitation-entry state.
+ /// The identity-bound recipient's provider key, or empty for email-targeted recipients.
+ /// The email-targeted recipient's invited address, or empty for identity-bound recipients.
+ /// The bounded failure reason, or on success.
+ ///
+ /// The recipient facts are captured here, from one configuration snapshot, and carried through the rest of
+ /// the request. The stages after this one await the session, and the options monitor can publish a new
+ /// configuration while that await is outstanding.
+ ///
+ readonly record struct EntryStateResolution(bool Succeeded, C.Invite Invite, InvitationEntryState EntryState, string RecipientProviderKey, string InvitedEmail, InvitationCompletionFailureReason Reason)
+ {
+ public static EntryStateResolution Success(C.Invite invite, InvitationEntryState entryState, string recipientProviderKey, string invitedEmail) =>
+ new(Succeeded: true, Invite: invite, EntryState: entryState, RecipientProviderKey: recipientProviderKey, InvitedEmail: invitedEmail, Reason: InvitationCompletionFailureReason.None);
+
+ public static EntryStateResolution Failure(InvitationCompletionFailureReason reason) =>
+ new(Succeeded: false, Invite: default!, EntryState: default!, RecipientProviderKey: string.Empty, InvitedEmail: string.Empty, Reason: reason);
+ }
+
+ ///
+ /// An attested invitation's verified-identity resolution. A failure carries either a bounded reason, or
+ /// - for the email-targeted recipient mode - the specific /
+ /// outcome.
+ ///
+ /// when a verified identity resolved.
+ /// The resolved verified identity.
+ /// The specific email-binding outcome for the email-targeted recipient mode.
+ /// The bounded failure reason, or on success.
+ readonly record struct VerifiedIdentityResolution(bool Succeeded, InvitationVerifiedIdentity Identity, InviteExchangeResult EmailOutcome, InvitationCompletionFailureReason Reason)
+ {
+ /// Gets the failure for a provider that supplied no address to bind the invitation against.
+ public static VerifiedIdentityResolution EmailUnavailable =>
+ new(Succeeded: false, Identity: default!, EmailOutcome: InviteExchangeResult.EmailUnavailable, Reason: InvitationCompletionFailureReason.None);
+
+ /// Gets the failure for a provider that supplied an unverified address, or somebody else's.
+ public static VerifiedIdentityResolution EmailMismatch =>
+ new(Succeeded: false, Identity: default!, EmailOutcome: InviteExchangeResult.EmailMismatch, Reason: InvitationCompletionFailureReason.None);
+
+ public static VerifiedIdentityResolution Success(InvitationVerifiedIdentity identity) =>
+ new(Succeeded: true, Identity: identity, EmailOutcome: InviteExchangeResult.Success, Reason: InvitationCompletionFailureReason.None);
+
+ public static VerifiedIdentityResolution Failure(InvitationCompletionFailureReason reason) =>
+ new(Succeeded: false, Identity: default!, EmailOutcome: InviteExchangeResult.Failed, Reason: reason);
+ }
}
diff --git a/Source/AuthProxy/Invites/InviteMiddleware.cs b/Source/AuthProxy/Invites/InviteMiddleware.cs
index a1bbe1f..280c726 100644
--- a/Source/AuthProxy/Invites/InviteMiddleware.cs
+++ b/Source/AuthProxy/Invites/InviteMiddleware.cs
@@ -19,13 +19,13 @@ namespace Cratis.AuthProxy.Invites;
/// Middleware that implements the two-phase invite flow:
///
/// -
-/// Handles /invite/{token} – validates the token, stores it in a short-lived
+/// Handles /invite/{token} – validates the token, stores it in a short-lived
/// HTTP-only cookie and redirects the user to the OIDC login.
/// If multiple identity providers are configured — or the caller already carries a session, whose
/// identity is not the one this invitation may bind — the invitation provider-selection page is served
/// so the user chooses which provider to complete the invitation with.
-/// If the token is expired the invitation-expired.html error page is returned.
-/// If the token is malformed or has an invalid signature the invitation-invalid.html page is returned.
+/// If the token is expired the invitation-expired.html error page is returned.
+/// If the token is malformed or has an invalid signature the invitation-invalid.html page is returned.
///
/// -
/// After a successful OIDC login – detects the pending invite cookie, confirms the session was
diff --git a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs
index bd7d8a4..4599062 100644
--- a/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs
+++ b/Source/AuthProxy/Invites/InviteMiddlewareLogging.cs
@@ -40,4 +40,7 @@ internal static partial void InvitationCompletionDestinationSelected(
this ILogger logger,
Configuration.InvitationCompletionDestination destination,
InvitationTenantRelation tenantRelation);
+
+ [LoggerMessage(LogLevel.Warning, "Attested invitation completion failed: {Reason}")]
+ internal static partial void AttestedInvitationCompletionFailed(this ILogger logger, InvitationCompletionFailureReason reason);
}
diff --git a/Source/AuthProxy/Links/LinkCallbackCompletion.cs b/Source/AuthProxy/Links/LinkCallbackCompletion.cs
index 08e316a..3dea0b0 100644
--- a/Source/AuthProxy/Links/LinkCallbackCompletion.cs
+++ b/Source/AuthProxy/Links/LinkCallbackCompletion.cs
@@ -22,7 +22,7 @@ namespace Cratis.AuthProxy.Links;
/// that the link did not complete, while the cause is logged where the operator can see it.
///
///
-/// Both outcomes end in HandleResponse(). That short-circuit is what stops the remote authentication
+/// Both outcomes end in HandleResponse(). That short-circuit is what stops the remote authentication
/// handler signing the second identity into the primary cookie scheme — without it a failed link quietly
/// swaps the account the person is signed in as.
///
diff --git a/Source/AuthProxy/Links/LinkFlowPages.cs b/Source/AuthProxy/Links/LinkFlowPages.cs
index 88d19f7..5ee2751 100644
--- a/Source/AuthProxy/Links/LinkFlowPages.cs
+++ b/Source/AuthProxy/Links/LinkFlowPages.cs
@@ -12,10 +12,10 @@ namespace Cratis.AuthProxy.Links;
/// embedding posture the flow requires.
///
///
-/// The link flow is designed to run inside an iframe on the product's page (see
+/// The link flow is designed to run inside an iframe on the product's page (see
/// ): the framed selection page opens the provider leg in a separate
/// top-level window — external identity providers refuse to render framed — and the completion and failure
-/// pages report back over a BroadcastChannel so the framed page can tell its parent the outcome.
+/// pages report back over a BroadcastChannel so the framed page can tell its parent the outcome.
/// Every page is served through so a deployment can restyle it, and every
/// page carries the frame-ancestors policy resolved from configuration — none by default.
///
diff --git a/Source/AuthProxy/Links/LinkMiddleware.cs b/Source/AuthProxy/Links/LinkMiddleware.cs
index 58b8c37..ef0fed8 100644
--- a/Source/AuthProxy/Links/LinkMiddleware.cs
+++ b/Source/AuthProxy/Links/LinkMiddleware.cs
@@ -11,23 +11,23 @@ namespace Cratis.AuthProxy.Links;
///
/// Middleware that serves the session-preserving credential-linking flow.
///
-/// A request to /.cratis/link/{scheme}?returnUrl=…&token=… triggers an OAuth/OIDC challenge for
+/// A request to /.cratis/link/{scheme}?returnUrl=…&token=… triggers an OAuth/OIDC challenge for
/// the requested provider — but, unlike the login flow, the resulting authentication does not
/// replace the primary session cookie. Instead the freshly authenticated subject is captured on the
/// provider callback and posted to the application (see
-/// OnTicketReceived and
+/// OnTicketReceived and
/// ). The link mode marker and the one-time link token travel through
/// the challenge's so the callback can recognize the flow.
///
///
-/// The bare /.cratis/link?token=… path serves the flow's embeddable provider-selection page, and
-/// /.cratis/link/complete the completion page a successful link ends on — see
+/// The bare /.cratis/link?token=… path serves the flow's embeddable provider-selection page, and
+/// /.cratis/link/complete the completion page a successful link ends on — see
/// for how the pages, the embedding product, and the provider window talk to
/// each other.
///
///
/// Linking only makes sense for an already signed-in user, so an unauthenticated request is rejected
-/// rather than challenged. The returnUrl is constrained to a same-site relative path so the flow
+/// rather than challenged. The returnUrl is constrained to a same-site relative path so the flow
/// can never be turned into an open redirect.
///
///
@@ -139,7 +139,7 @@ static string ResolveReturnUrl(string? returnUrl)
///
/// Determines whether the request is a navigation inside a frame, going by the browser-set
- /// Sec-Fetch-Dest fetch metadata header.
+ /// Sec-Fetch-Dest fetch metadata header.
///
/// The current .
/// when the navigation targets a frame; otherwise .
diff --git a/Source/AuthProxy/LogoutMiddleware.cs b/Source/AuthProxy/LogoutMiddleware.cs
index 305f220..96ce01d 100644
--- a/Source/AuthProxy/LogoutMiddleware.cs
+++ b/Source/AuthProxy/LogoutMiddleware.cs
@@ -16,8 +16,8 @@ namespace Cratis.AuthProxy;
/// Middleware that handles the well-known logout endpoint () and its
/// post-logout callback (). It performs a full-chain logout:
/// when the session was established through an OIDC provider it initiates RP-initiated logout by redirecting
-/// the browser to that provider's end-session endpoint with an id_token_hint and a
-/// post_logout_redirect_uri pointing back to the callback; the callback then clears every AuthProxy
+/// the browser to that provider's end-session endpoint with an id_token_hint and a
+/// post_logout_redirect_uri pointing back to the callback; the callback then clears every AuthProxy
/// cookie and redirects to the validated final destination. OAuth 2.0 providers (such as GitHub) have no
/// standard OIDC end-session endpoint, so for those — and whenever there is no active OIDC session — it
/// falls back to a local-only logout that clears cookies and redirects directly.
diff --git a/Source/AuthProxy/Management/DataProtectionReadiness.cs b/Source/AuthProxy/Management/DataProtectionReadiness.cs
index 72c6ba9..828b629 100644
--- a/Source/AuthProxy/Management/DataProtectionReadiness.cs
+++ b/Source/AuthProxy/Management/DataProtectionReadiness.cs
@@ -15,8 +15,8 @@ namespace Cratis.AuthProxy.Management;
/// The key ring is what encrypts the authentication cookie and the AuthProxy-issued client-credentials
/// tokens, so an instance whose key ring will not initialize cannot serve a single authenticated request —
/// yet it accepts sockets perfectly well, which is exactly why a TCP probe reports it healthy and sends it
-/// traffic. A Protect/Unprotect round-trip is the only thing that forces initialization and
-/// proves it against the configured DataProtectionKeysPath rather than against a cached opinion.
+/// traffic. A Protect/Unprotect round-trip is the only thing that forces initialization and
+/// proves it against the configured DataProtectionKeysPath rather than against a cached opinion.
///
/// It is re-run on every call, and no answer is remembered. A key ring that becomes unusable — a volume
/// unmounted, a permission revoked — has to change the answer, and a cached "ready" would keep an instance
diff --git a/Source/AuthProxy/Management/ListenerAddresses.cs b/Source/AuthProxy/Management/ListenerAddresses.cs
index e7aa8f7..4ca61c4 100644
--- a/Source/AuthProxy/Management/ListenerAddresses.cs
+++ b/Source/AuthProxy/Management/ListenerAddresses.cs
@@ -11,8 +11,8 @@ namespace Cratis.AuthProxy.Management;
///
///
/// Adding a listener means re-declaring the existing ones alongside it, so what they are has to be answered
-/// before anything is changed. The host resolves them in a specific order — the urls setting first
-/// (ASPNETCORE_URLS), then the HTTP_PORTS and HTTPS_PORTS settings, which is how the
+/// before anything is changed. The host resolves them in a specific order — the urls setting first
+/// (ASPNETCORE_URLS), then the HTTP_PORTS and HTTPS_PORTS settings, which is how the
/// official .NET container images publish port 8080 — and reading only the first of those would silently
/// unbind the public listener of every containerized deployment.
///
@@ -56,7 +56,7 @@ .. Expand(configuration[WebHostDefaults.HttpsPortsKey], Uri.UriSchemeHttps)
///
/// Gets the port an address names.
///
- /// The address, for example http://+:8080 or http://[::1]:9110.
+ /// The address, for example http://+:8080 or http://[::1]:9110.
/// The port, or when the address names none.
public static int? PortOf(string address)
{
diff --git a/Source/AuthProxy/Management/ManagementEndpoints.cs b/Source/AuthProxy/Management/ManagementEndpoints.cs
index 8701ebe..35f1229 100644
--- a/Source/AuthProxy/Management/ManagementEndpoints.cs
+++ b/Source/AuthProxy/Management/ManagementEndpoints.cs
@@ -16,7 +16,7 @@ namespace Cratis.AuthProxy.Management;
/// without a credential by design, so anything it says is said to whoever can reach the port.
///
/// Liveness consults nothing at all: no dependency is resolved and no I/O is performed, so it answers
-/// 200 for as long as the request loop is servicing requests, including while every dependency is
+/// 200 for as long as the request loop is servicing requests, including while every dependency is
/// unreachable. That is what stops an orchestrator restarting a healthy proxy during somebody else's
/// outage.
///
diff --git a/Source/AuthProxy/Management/ManagementExtensions.cs b/Source/AuthProxy/Management/ManagementExtensions.cs
index 6ce8c5d..12ddebc 100644
--- a/Source/AuthProxy/Management/ManagementExtensions.cs
+++ b/Source/AuthProxy/Management/ManagementExtensions.cs
@@ -23,10 +23,10 @@ public static class ManagementExtensions
/// socket is opened, and the process binds exactly what it binds today.
///
/// ⚠️ The listener is added by re-declaring the host's addresses, never with
- /// ConfigureKestrel(options => options.Listen(...)). Populating
- /// KestrelServerOptions.ListenOptions makes Kestrel discard the hosting addresses entirely
- /// whenever PreferHostingUrls is left at its default of — it logs
- /// "Overriding address(es)" and binds only what was passed to Listen. The public listener of
+ /// ConfigureKestrel(options => options.Listen(...)). Populating
+ /// KestrelServerOptions.ListenOptions makes Kestrel discard the hosting addresses entirely
+ /// whenever PreferHostingUrls is left at its default of — it logs
+ /// "Overriding address(es)" and binds only what was passed to Listen. The public listener of
/// every containerized deployment comes from those hosting addresses, so an opt-in health endpoint
/// would take the whole proxy off the network. Both listeners have to come from the same place, and
/// the public one is already in the addresses.
diff --git a/Source/AuthProxy/Management/ManagementListenerIsolation.cs b/Source/AuthProxy/Management/ManagementListenerIsolation.cs
index 5a34dcc..f68eaed 100644
--- a/Source/AuthProxy/Management/ManagementListenerIsolation.cs
+++ b/Source/AuthProxy/Management/ManagementListenerIsolation.cs
@@ -11,15 +11,15 @@ namespace Cratis.AuthProxy.Management;
/// The path answering readiness.
///
/// Isolation is gated on — the socket Kestrel accepted the request
-/// on — and deliberately not on the Host header. ASP.NET's own port-scoping convention,
-/// RequireHost("*:9110"), matches that header, and a header is whatever the caller wrote: a request
-/// arriving on the public listener carrying Host: anything:9110 would be treated as a management
+/// on — and deliberately not on the Host header. ASP.NET's own port-scoping convention,
+/// RequireHost("*:9110"), matches that header, and a header is whatever the caller wrote: a request
+/// arriving on the public listener carrying Host: anything:9110 would be treated as a management
/// request and answered from a surface that is supposed to be unreachable from the network. The accepted
/// socket cannot be forged by a caller, which is the entire point.
///
/// It gates in both directions. The management paths answer only on the management port, so probing the
/// public listener for them gets the same not-found as any other unknown path; and the management port
-/// answers only those paths, so nothing that arrives on it — not /, not a declared anonymous path,
+/// answers only those paths, so nothing that arrives on it — not /, not a declared anonymous path,
/// not a bundled asset — is ever handed to the middleware pipeline or the reverse proxy.
///
///
diff --git a/Source/AuthProxy/PostLogoutRedirectPolicy.cs b/Source/AuthProxy/PostLogoutRedirectPolicy.cs
index 01a0c0e..3312d99 100644
--- a/Source/AuthProxy/PostLogoutRedirectPolicy.cs
+++ b/Source/AuthProxy/PostLogoutRedirectPolicy.cs
@@ -14,13 +14,13 @@ namespace Cratis.AuthProxy;
/// relative-URL check used for tenant selection. Instead it is validated against an allow-list of origins
/// that combines the proxy's own public origin with the configured service frontends, the lobby frontend,
/// and any explicitly configured origins. A missing or disallowed target falls back to the application root
-/// (/).
+/// (/).
///
/// Only the scheme half of that self-origin is bounded by the trusted-proxy boundary, because that
-/// is the half the forwarded-headers middleware settles. The host half is Request.Host as it
-/// arrived, and the shipped AllowedHosts is *, so a caller that can choose the Host
+/// is the half the forwarded-headers middleware settles. The host half is Request.Host as it
+/// arrived, and the shipped AllowedHosts is *, so a caller that can choose the Host
/// header can put its own origin on this list and be redirected back to it after logout. Bound it with
-/// AllowedHosts — the ASP.NET host-filtering setting — to make the self-origin mean the deployment's
+/// AllowedHosts — the ASP.NET host-filtering setting — to make the self-origin mean the deployment's
/// own name rather than whatever was asked for.
///
///
diff --git a/Source/AuthProxy/ProxyOwnedFlowMiddleware.cs b/Source/AuthProxy/ProxyOwnedFlowMiddleware.cs
index 26d0e3b..af262c5 100644
--- a/Source/AuthProxy/ProxyOwnedFlowMiddleware.cs
+++ b/Source/AuthProxy/ProxyOwnedFlowMiddleware.cs
@@ -9,10 +9,10 @@ namespace Cratis.AuthProxy;
///
/// The next middleware in the pipeline.
///
-/// anchors endpoint matching at UseRouting, and YARP's
+/// anchors endpoint matching at UseRouting, and YARP's
/// catch-all route matches every path — including the two prefixes that are reserved from every service
/// precisely because no service ever serves them. Those routes are generated with the default authorization
-/// policy, which is RequireAuthenticatedUser, so UseAuthorization refused an invitation or a
+/// policy, which is RequireAuthenticatedUser, so UseAuthorization refused an invitation or a
/// registration before or
/// — both registered after it — could run at all. The
/// visible symptom was an invitation link that answered with provider selection and no pending-invitation
@@ -22,8 +22,8 @@ namespace Cratis.AuthProxy;
///
/// The endpoint is cleared rather than the authorization step skipped. Skipping it would leave the
/// catch-all's authorization metadata on a request that never evaluated it, which
-/// EndpointMiddleware refuses outright; clearing it removes the claim on the path instead, leaving
-/// UseAuthorization to find nothing to enforce and pass the request on. That says what is true —
+/// EndpointMiddleware refuses outright; clearing it removes the claim on the path instead, leaving
+/// UseAuthorization to find nothing to enforce and pass the request on. That says what is true —
/// these paths belong to the proxy, not to a route — and makes it impossible for one to be proxied to a
/// backend by a route that matched it only for want of a more specific one.
///
diff --git a/Source/AuthProxy/Registrations/RegistrationMiddleware.cs b/Source/AuthProxy/Registrations/RegistrationMiddleware.cs
index 39745cc..72e9636 100644
--- a/Source/AuthProxy/Registrations/RegistrationMiddleware.cs
+++ b/Source/AuthProxy/Registrations/RegistrationMiddleware.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Registrations;
///
/// Middleware that implements the AuthProxy registration bootstrap flow.
-/// Visiting /register stores a short-lived cookie and either redirects to the generic
+/// Visiting /register stores a short-lived cookie and either redirects to the generic
/// provider-selection page or immediately challenges the single configured provider.
/// After authentication completes, the middleware redirects the user to the configured
/// lobby registration endpoint.
diff --git a/Source/AuthProxy/RelativeRedirect.cs b/Source/AuthProxy/RelativeRedirect.cs
index df66761..e3e71cc 100644
--- a/Source/AuthProxy/RelativeRedirect.cs
+++ b/Source/AuthProxy/RelativeRedirect.cs
@@ -9,23 +9,23 @@ namespace Cratis.AuthProxy;
///
///
/// AuthProxy hands the browser a redirect target the caller supplied in four places — the login endpoint's
-/// returnUrl, the link flow's returnUrl, tenant selection's returnUrl, and logout's
-/// redirect. Each had grown its own version of "is this relative", and they disagreed: one accepted
-/// //evil.test outright, and the two that rejected it still accepted /\evil.test. An open
+/// returnUrl, the link flow's returnUrl, tenant selection's returnUrl, and logout's
+/// redirect. Each had grown its own version of "is this relative", and they disagreed: one accepted
+/// //evil.test outright, and the two that rejected it still accepted /\evil.test. An open
/// redirect on the authentication proxy is the strongest phishing primitive a system can offer — the
/// victim sees the real domain, completes a real login at the real identity provider, and only then lands
/// on the attacker's page — so the check lives here once rather than being re-derived per call site.
///
-/// A single leading / is not enough to be same-site, because the browser decides what a
-/// Location means, not the string's first character:
+/// A single leading / is not enough to be same-site, because the browser decides what a
+/// Location means, not the string's first character:
///
///
-/// - //evil.test is protocol-relative and navigates off-site.
-/// - /\evil.test is the same thing to every major browser, which normalize \ to
-/// / in the authority position.
-/// - / followed by a tab, carriage return or newline is also the same thing:
-/// browsers strip those characters from a URL before parsing it, so /\tevil.test is fetched as
-/// //evil.test. Control characters are what make a header-injection payload too, so they are
+///
- //evil.test is protocol-relative and navigates off-site.
+/// - /\evil.test is the same thing to every major browser, which normalize \ to
+/// / in the authority position.
+/// - / followed by a tab, carriage return or newline is also the same thing:
+/// browsers strip those characters from a URL before parsing it, so /\tevil.test is fetched as
+/// //evil.test. Control characters are what make a header-injection payload too, so they are
/// refused rather than stripped.
///
///
diff --git a/Source/AuthProxy/RequestPathRedaction.cs b/Source/AuthProxy/RequestPathRedaction.cs
index faeabec..9c3f77f 100644
--- a/Source/AuthProxy/RequestPathRedaction.cs
+++ b/Source/AuthProxy/RequestPathRedaction.cs
@@ -7,7 +7,7 @@ namespace Cratis.AuthProxy;
/// Reduces a request path to the bounded route label that may be written to a log sink.
///
///
-/// An invitation arrives as /invite/{capability}, so on a Phase-1 invitation request the request path
+/// An invitation arrives as /invite/{capability}, so on a Phase-1 invitation request the request path
/// is a live bearer capability. Rendering a raw path into a log therefore hands that capability to
/// every sink the logs reach. Only the leading route segment is bounded by what this proxy routes rather than
/// by what a caller put in the URL, so that is all this keeps; everything after it becomes a fixed marker.
diff --git a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
index a6b4747..ee74a61 100644
--- a/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
+++ b/Source/AuthProxy/ReverseProxy/MicroserviceReverseProxyConfigProvider.cs
@@ -14,8 +14,8 @@ namespace Cratis.AuthProxy.ReverseProxy;
///
/// Each microservice generates routes that are matched by either:
///
-/// - An Microservice-ID HTTP header set to the microservice name, or
-/// - A microservice query-string parameter set to the microservice name.
+/// - An Microservice-ID HTTP header set to the microservice name, or
+/// - A microservice query-string parameter set to the microservice name.
///
///
///
@@ -175,18 +175,18 @@ static void ReportRefusedAnonymousPaths(string microserviceKey, C.Service servic
/// The logger, used to name a prefix an earlier service already claimed.
/// One route per declared anonymous path prefix not already claimed.
///
- /// These are the only routes not generated with AuthorizationPolicy = "default". That default is
- /// RequireAuthenticatedUser(), so without this a declared anonymous path clears
- /// SelectProviderMiddleware only to be stopped one step later — refused by authorization on the
+ /// These are the only routes not generated with AuthorizationPolicy = "default". That default is
+ /// RequireAuthenticatedUser(), so without this a declared anonymous path clears
+ /// SelectProviderMiddleware only to be stopped one step later — refused by authorization on the
/// catch-all route in a single-service deployment, or matching no route at all in a multi-service one,
/// where every other route is selected by a header or query parameter an anonymous caller has no reason
/// to send. The same closed door either way. None of the built-in skip-list paths (invite,
- /// registration, authentication UI, /_pages) is ever proxied to a service, so this is the first
+ /// registration, authentication UI, /_pages) is ever proxied to a service, so this is the first
/// case where an unauthenticated request is meant to reach a backend, and the first that needs the
/// policy relaxed.
///
/// The relaxation is scoped to exactly the declared prefixes and nothing else: with no
- /// AnonymousPaths declared this yields no routes and the table is what it was before. Each
+ /// AnonymousPaths declared this yields no routes and the table is what it was before. Each
/// prefix is emitted as a catch-all so it covers the prefix itself and everything under it, which
/// matches the segment-prefix semantics the middlewares apply because
/// only admits prefixes made of literal segments.
diff --git a/Source/AuthProxy/SignIns/ClientLocation.cs b/Source/AuthProxy/SignIns/ClientLocation.cs
index 739f264..aac9d38 100644
--- a/Source/AuthProxy/SignIns/ClientLocation.cs
+++ b/Source/AuthProxy/SignIns/ClientLocation.cs
@@ -9,11 +9,11 @@ namespace Cratis.AuthProxy.SignIns;
///
///
/// The resolved client IP address — the connection's remote address as the forwarded-headers middleware left
-/// it, so X-Forwarded-For is honored exactly as far as the configured trusted-proxy boundary allows.
+/// it, so X-Forwarded-For is honored exactly as far as the configured trusted-proxy boundary allows.
/// Empty when it cannot be resolved.
///
///
-/// A best-effort, coarse location string (for example "San Francisco, California, US" or "US"),
+/// A best-effort, coarse location string (for example "San Francisco, California, US" or "US"),
/// assembled from geo headers a fronting CDN/proxy may add. Empty when no geo information is available, and
/// empty when the request did not come from a trusted proxy — an untrusted caller's geo headers are values
/// it chose, not facts about where it is.
diff --git a/Source/AuthProxy/SignIns/ClientLocationResolver.cs b/Source/AuthProxy/SignIns/ClientLocationResolver.cs
index f0bccac..7e26a12 100644
--- a/Source/AuthProxy/SignIns/ClientLocationResolver.cs
+++ b/Source/AuthProxy/SignIns/ClientLocationResolver.cs
@@ -18,8 +18,8 @@ namespace Cratis.AuthProxy.SignIns;
/// - the client IP, taken from the connection's remote address as the forwarded-headers middleware
/// left it, which is the address of the trusted proxy's declared client when the request came through
/// the deployment's own infrastructure and the caller's own address otherwise; and
-/// - coarse geo headers that popular fronting layers add — Cloudflare's CF-IPCountry, and the
-/// conventional X-Geo-* / X-AppEngine-* city/region/country headers — read only when the
+///
- coarse geo headers that popular fronting layers add — Cloudflare's CF-IPCountry, and the
+/// conventional X-Geo-* / X-AppEngine-* city/region/country headers — read only when the
/// request came from a trusted proxy.
///
///
diff --git a/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs b/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs
index da1ff70..b6aae80 100644
--- a/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs
+++ b/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs
@@ -13,7 +13,7 @@ public interface ISignInNotificationSigner
///
///
/// When this is a notification is posted exactly as it always has been — unsigned,
- /// with no Authorization header. When it is an unsigned notification is never
+ /// with no Authorization header. When it is an unsigned notification is never
/// an acceptable outcome: a caller that cannot obtain an envelope must refuse to post at all.
///
bool IsEnabled { get; }
diff --git a/Source/AuthProxy/SignIns/SignInAttestationClaims.cs b/Source/AuthProxy/SignIns/SignInAttestationClaims.cs
index d42a5b7..aa21413 100644
--- a/Source/AuthProxy/SignIns/SignInAttestationClaims.cs
+++ b/Source/AuthProxy/SignIns/SignInAttestationClaims.cs
@@ -9,9 +9,9 @@ namespace Cratis.AuthProxy.SignIns;
///
/// The envelope is a profile of RFC 9449 (DPoP) rather than a scheme of its own: and
/// are the RFC 9449 route claims with the RFC 9449 semantics, and the standard
-/// iss, aud, iat, nbf, exp and jti claims carry provenance, audience,
+/// iss, aud, iat, nbf, exp and jti claims carry provenance, audience,
/// time and replay resistance. is the one AuthProxy extension — RFC 9449 has no body
-/// digest — and uses the identical construction to its ath claim.
+/// digest — and uses the identical construction to its ath claim.
///
public static class SignInAttestationClaims
{
diff --git a/Source/AuthProxy/SignIns/SignInNotificationSigner.cs b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs
index 5f60df0..00a4d7c 100644
--- a/Source/AuthProxy/SignIns/SignInNotificationSigner.cs
+++ b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs
@@ -17,10 +17,10 @@ namespace Cratis.AuthProxy.SignIns;
///
/// The envelope binds six facts about the notification it accompanies, so that reaching the application's
/// private endpoint is no longer enough to choose which user it records as having signed in: provenance
-/// (iss plus the kid header selecting the key), audience (aud), route
+/// (iss plus the kid header selecting the key), audience (aud), route
/// ( and ), body
-/// ( over the exact bytes posted), time (iat, nbf,
-/// exp) and replay (a random jti). Provenance, audience, time and replay come from
+/// ( over the exact bytes posted), time (iat, nbf,
+/// exp) and replay (a random jti). Provenance, audience, time and replay come from
/// — the one signing implementation, shared with invitation attestation — and
/// route and body are added here.
///
diff --git a/Source/AuthProxy/SignIns/UserAgentInfo.cs b/Source/AuthProxy/SignIns/UserAgentInfo.cs
index f50c246..cd6e6dc 100644
--- a/Source/AuthProxy/SignIns/UserAgentInfo.cs
+++ b/Source/AuthProxy/SignIns/UserAgentInfo.cs
@@ -4,11 +4,11 @@
namespace Cratis.AuthProxy.SignIns;
///
-/// Represents the browser and operating system parsed from a request's User-Agent header.
+/// Represents the browser and operating system parsed from a request's User-Agent header.
///
-/// The browser name (for example Chrome, Safari, Firefox), or an empty string when unknown.
-/// The operating system name (for example Windows, macOS, iOS), or an empty string when unknown.
-/// The raw User-Agent header value.
+/// The browser name (for example Chrome, Safari, Firefox), or an empty string when unknown.
+/// The operating system name (for example Windows, macOS, iOS), or an empty string when unknown.
+/// The raw User-Agent header value.
public record UserAgentInfo(string Browser, string OperatingSystem, string Raw)
{
///
diff --git a/Source/AuthProxy/SignIns/UserAgentParser.cs b/Source/AuthProxy/SignIns/UserAgentParser.cs
index a5e065e..32a87ba 100644
--- a/Source/AuthProxy/SignIns/UserAgentParser.cs
+++ b/Source/AuthProxy/SignIns/UserAgentParser.cs
@@ -4,7 +4,7 @@
namespace Cratis.AuthProxy.SignIns;
///
-/// Parses a User-Agent header into a coarse browser and operating-system description.
+/// Parses a User-Agent header into a coarse browser and operating-system description.
///
///
/// This is a deliberately lightweight, dependency-free heuristic — it recognizes the mainstream browsers and
@@ -15,9 +15,9 @@ namespace Cratis.AuthProxy.SignIns;
public static class UserAgentParser
{
///
- /// Parses the supplied User-Agent header value.
+ /// Parses the supplied User-Agent header value.
///
- /// The raw User-Agent header value.
+ /// The raw User-Agent header value.
/// The parsed ; when the value is empty.
public static UserAgentInfo Parse(string? userAgent)
{
diff --git a/Source/AuthProxy/Tenancy/ClaimSourceIdentifierStrategy.cs b/Source/AuthProxy/Tenancy/ClaimSourceIdentifierStrategy.cs
index 216f55b..fa708f8 100644
--- a/Source/AuthProxy/Tenancy/ClaimSourceIdentifierStrategy.cs
+++ b/Source/AuthProxy/Tenancy/ClaimSourceIdentifierStrategy.cs
@@ -7,11 +7,11 @@
namespace Cratis.AuthProxy.Tenancy;
///
-/// Resolves the tenant source identifier from a claim in the x-ms-client-principal
+/// Resolves the tenant source identifier from a claim in the x-ms-client-principal
/// that is already present on the request (set by a prior authentication step).
/// Uses the Microsoft standard tenant claim
-/// http://schemas.microsoft.com/identity/claims/tenantid by default,
-/// but the claim type can be overridden via the claimType option.
+/// http://schemas.microsoft.com/identity/claims/tenantid by default,
+/// but the claim type can be overridden via the claimType option.
///
public class ClaimSourceIdentifierStrategy : ISourceIdentifierStrategyTyped
{
diff --git a/Source/AuthProxy/Tenancy/RouteSourceIdentifierStrategy.cs b/Source/AuthProxy/Tenancy/RouteSourceIdentifierStrategy.cs
index 426f629..516b456 100644
--- a/Source/AuthProxy/Tenancy/RouteSourceIdentifierStrategy.cs
+++ b/Source/AuthProxy/Tenancy/RouteSourceIdentifierStrategy.cs
@@ -7,9 +7,9 @@ namespace Cratis.AuthProxy.Tenancy;
///
/// Resolves the tenant source identifier from the request path using a named-group
-/// regular expression. The named group must be called sourceIdentifier.
-/// Configure the expression via the pattern option, e.g.:
-/// \/(?<sourceIdentifier>[\w]+)\/.
+/// regular expression. The named group must be called sourceIdentifier.
+/// Configure the expression via the pattern option, e.g.:
+/// \/(?<sourceIdentifier>[\w]+)\/.
///
public class RouteSourceIdentifierStrategy : ISourceIdentifierStrategyTyped
{
diff --git a/Source/AuthProxy/Tenancy/SpecifiedSourceIdentifierStrategy.cs b/Source/AuthProxy/Tenancy/SpecifiedSourceIdentifierStrategy.cs
index 8b9d5ff..d93217d 100644
--- a/Source/AuthProxy/Tenancy/SpecifiedSourceIdentifierStrategy.cs
+++ b/Source/AuthProxy/Tenancy/SpecifiedSourceIdentifierStrategy.cs
@@ -6,7 +6,7 @@
namespace Cratis.AuthProxy.Tenancy;
///
-/// Always resolves to the tenant ID specified in the tenantId option.
+/// Always resolves to the tenant ID specified in the tenantId option.
/// Used for single-tenant deployments.
///
public class SpecifiedSourceIdentifierStrategy : ISourceIdentifierStrategyTyped
diff --git a/Source/AuthProxy/Tenancy/SubHostOptions.cs b/Source/AuthProxy/Tenancy/SubHostOptions.cs
index 20b9eb7..bed8023 100644
--- a/Source/AuthProxy/Tenancy/SubHostOptions.cs
+++ b/Source/AuthProxy/Tenancy/SubHostOptions.cs
@@ -10,14 +10,14 @@ public record SubHostOptions
{
///
/// Gets the parent host used to extract the tenant from the request host.
- /// For example, with ParentHost set to example.com,
- /// a host of acme.example.com resolves the tenant ID acme.
+ /// For example, with ParentHost set to example.com,
+ /// a host of acme.example.com resolves the tenant ID acme.
///
public string? ParentHost { get; init; }
///
/// Gets an optional URL template used to verify resolved subhost tenant IDs.
- /// Use {tenantId} as a placeholder.
+ /// Use {tenantId} as a placeholder.
///
public string? VerificationUrlTemplate { get; init; }
}
diff --git a/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs b/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs
index d6ef099..b727cd6 100644
--- a/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs
+++ b/Source/AuthProxy/Tenancy/TenantResolutionOptionsConfigurer.cs
@@ -11,7 +11,7 @@ namespace Cratis.AuthProxy.Tenancy;
/// to its concrete typed options class based on the discriminator.
///
///
-/// Because is typed as object?, the standard configuration
+/// Because is typed as object?, the standard configuration
/// binder cannot determine which concrete type to instantiate. This post-configure step reads the raw
/// sub-section for each resolution entry and binds it to the correct typed
/// options record, ensuring full environment-variable support and compile-time safety.
diff --git a/Source/AuthProxy/TenantSelectionMiddleware.cs b/Source/AuthProxy/TenantSelectionMiddleware.cs
index 62e5bda..1278efe 100644
--- a/Source/AuthProxy/TenantSelectionMiddleware.cs
+++ b/Source/AuthProxy/TenantSelectionMiddleware.cs
@@ -338,9 +338,9 @@ sealed record TenantOption(
[property: System.Text.Json.Serialization.JsonPropertyName("name")] string Name);
///
- /// The outcome of calling the tenant endpoint. Succeeded is only when
+ /// The outcome of calling the tenant endpoint. Succeeded is only when
/// the endpoint could not give an authoritative answer (unreachable, server error, unparseable body);
- /// an authoritative "no tenants" answer has Succeeded with an empty list.
+ /// an authoritative "no tenants" answer has Succeeded with an empty list.
///
/// Whether the endpoint gave an authoritative answer.
/// The tenants available to the user when the call succeeded.
diff --git a/Source/AuthProxy/UniformDenial.cs b/Source/AuthProxy/UniformDenial.cs
index 24e15ef..a61faf7 100644
--- a/Source/AuthProxy/UniformDenial.cs
+++ b/Source/AuthProxy/UniformDenial.cs
@@ -10,8 +10,8 @@ namespace Cratis.AuthProxy;
///
///
/// Every refusal has to be the same refusal — same status, same headers, same bytes — because anything that
-/// varies is an answer. A different status for a path that exists, a WWW-Authenticate where a
-/// challenge would have started, an Allow listing the methods a route accepts, a Location
+/// varies is an answer. A different status for a path that exists, a WWW-Authenticate where a
+/// challenge would have started, an Allow listing the methods a route accepts, a Location
/// pointing at a provider, a cookie issued on the way out: each of those is a question answered for a
/// caller who has presented nothing.
///
@@ -29,8 +29,8 @@ namespace Cratis.AuthProxy;
///
///
/// It is deliberately not written through . That
-/// provider injects a <base href="/_pages/"> so a branded page can load its assets, and
-/// /_pages is one of the things a closed deployment closes — so a branded refusal would render
+/// provider injects a <base href="/_pages/"> so a branded page can load its assets, and
+/// /_pages is one of the things a closed deployment closes — so a branded refusal would render
/// without its assets and, worse, would be a distinguishable answer: it says an AuthProxy is here and that
/// it has pages.
///
diff --git a/Source/Web/yarn.lock b/Source/Web/yarn.lock
index 550f969..d5126ca 100644
--- a/Source/Web/yarn.lock
+++ b/Source/Web/yarn.lock
@@ -2138,13 +2138,13 @@ __metadata:
linkType: hard
"js-yaml@npm:^4.3.0":
- version: 4.3.1
- resolution: "js-yaml@npm:4.3.1"
+ version: 4.3.2
+ resolution: "js-yaml@npm:4.3.2"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
- checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42
+ checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7
languageName: node
linkType: hard