Skip to content

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.PublicBaseUrl instead of deriving it. Points 1, 3 and 4 — AppUrl is 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 ​

  1. AppUrl = Kestrel bind address only — explicitly documented as not the public origin. Defaults to plain HTTP behind a TLS-terminating proxy.
  2. Public origin (outbound links + WebAuthn RP) is derived per-realm from Realm.PrimaryDomain via the single RealmPublicUrl helper; it throws on an empty host rather than emit a host-less link.
  3. OIDC issuer is derived per-request from the validated forwarded host (BaseUri). There is no configurable issuer (removed; OpenIddict is given a deliberately-unroutable placeholder https://issuer.invalid/ which is overridden on every issuer-emitting path). The override sites are: discovery (RealmIssuerHandler), the token iss claim (RealmSigningKeyHandler), token validation (RealmTokenValidationHandler), and the RFC 9207 authorization-response iss (RealmAuthorizationResponseIssuerHandler). The last was missing until PR #70 — the placeholder leaked into the authorize redirect; see Consequences.
  4. 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 all X-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/-Proto spoofing.

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 carried iss=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. letting Options.Issuer be null so OpenIddict's Options.Issuer ?? BaseUri falls back to BaseUri everywhere — needs verifying OpenIddict tolerates a null issuer at startup).
  • Operational requirement: the reverse proxy MUST forward Host/Proto, and ProxyAllowedNetworks MUST be set in Production.
  • Cross-project: the same PublicUrl anti-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 handlers RealmIssuerHandler / RealmSigningKeyHandler / RealmTokenValidationHandler / RealmAuthorizationResponseIssuerHandler.
  • See ADR-0004 (tenancy) for the per-realm model Realm.PrimaryDomain belongs to; ADR-0008 (CIMD) surfaced the RFC 9207 gap.

Released under the Apache-2.0 License.