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.comacme). + /// (e.g. acme.example.comacme). /// /// 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