Public origin is derived, not configured (per-realm host, request-derived issuer, fail-closed proxy trust)
Status: Partly superseded · Decided: 2026-06-13
Point 2 was superseded 2026-09-03 by ADR 0023: Modgud now declares the public origin per realm via
Realm.PublicBaseUrlinstead of deriving it. Points 1, 3 and 4 —AppUrlis only the bind address, the issuer is derived per request, forwarded-header trust fails closed — still describe the code.
Context
The codebase inherited from an earlier reference template a single instance-wide PublicUrl setting. It conflated three distinct concerns into one value: the Kestrel bind address, the public-facing origin (used for outbound email links + the WebAuthn relying-party ID), and the OIDC issuer. One global value cannot be correct in a multi-realm / multi-domain deployment, and as a static config it silently drifts. Separately, a configurable OpenIddict__Issuer existed but never took effect (the issuer is request-derived), which gave false assurance.
Decision
AppUrl= Kestrel bind address only — explicitly documented as not the public origin. Defaults to plain HTTP behind a TLS-terminating proxy.- Public origin (outbound links + WebAuthn RP) is derived per-realm from
Realm.PrimaryDomainvia the singleRealmPublicUrlhelper; it throws on an empty host rather than emit a host-less link. - OIDC issuer is derived per-request from the validated forwarded host (
BaseUri). There is no configurable issuer (removed; OpenIddict is given a deliberately-unroutable placeholderhttps://issuer.invalid/which is overridden on every issuer-emitting path). The override sites are: discovery (RealmIssuerHandler), the tokenissclaim (RealmSigningKeyHandler), token validation (RealmTokenValidationHandler), and the RFC 9207 authorization-responseiss(RealmAuthorizationResponseIssuerHandler). The last was missing until PR #70 — the placeholder leaked into the authorize redirect; see Consequences. - Forwarded-header trust fails closed.
ProxyAllowedNetworks(CIDR allow-list) pins the trusted proxy range; when unset in Production an unroutable RFC 5737 sentinel keeps ASP.NET Core's known-IP check active so allX-Forwarded-*are rejected — because empty known-lists make the middleware trust-all, not reject-all (the PROD-03 inversion bug).
Alternatives considered (and rejected)
- Global
PublicUrl(inherited from the earlier reference template): rejected — conflates bind vs. origin, impossible for >1 host, drifts. - Configurable issuer: rejected — never emitted (issuer is per-realm/request); a knob that does nothing is worse than none (false assurance; audit L1).
- Leaving the proxy known-lists empty: rejected — ASP.NET Core treats that as trust-all, enabling
X-Forwarded-Host/-Protospoofing.
Consequences
- Correct across multiple realms/domains; the issuer and outbound links always match the host the client reached.
- The placeholder-issuer approach has a cost: every path that emits the issuer must override the placeholder, and missing one is a silent, latent bug. This bit the authorization-response
iss(RFC 9207): discovery advertised the real issuer +authorization_response_iss_parameter_supported=true, but the redirect carriediss=https://issuer.invalid/→ strict clients (rising MCP cohort) reject. Fixed in PR #70 (RealmAuthorizationResponseIssuerHandler). When adding any new issuer-emitting surface, override the placeholder there too (or reconsider whether the placeholder is worth the whack-a-mole vs. lettingOptions.Issuerbe null so OpenIddict'sOptions.Issuer ?? BaseUrifalls back toBaseUrieverywhere — needs verifying OpenIddict tolerates a null issuer at startup). - Operational requirement: the reverse proxy MUST forward
Host/Proto, andProxyAllowedNetworksMUST be set in Production. - Cross-project: the same
PublicUrlanti-pattern exists in the shared template codebase Modgud was originally based on; a feature-request/lesson was filed there.
References
- Code:
Modgud.Authentication/RealmPublicUrl.cs,Modgud.Api/ForwardedHeadersTrust.cs, and the issuer handlersRealmIssuerHandler/RealmSigningKeyHandler/RealmTokenValidationHandler/RealmAuthorizationResponseIssuerHandler. - See ADR-0004 (tenancy) for the per-realm model
Realm.PrimaryDomainbelongs to; ADR-0008 (CIMD) surfaced the RFC 9207 gap.