Skip to content

Add OIDC sign-in for the web dashboard (#2550) - #2730

Closed
erikdarlingdata wants to merge 6 commits into
devfrom
feat/2550-oidc
Closed

erikdarlingdata wants to merge 6 commits into
devfrom
feat/2550-oidc

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Phase 1 is carved out into #2744 (non-draft, based on current dev) and is the PR to review now: OIDC token validation + claim-to-role mapping behind config, with the shared-token default untouched — the security-bearing core, reviewable on its own.

This draft holds what remains, both of which are written and live on the feat/2550-oidc branch here:

  • Per-user audit attribution — /api/session reports the signed-in subject and the seat's real can_edit; updated_by stamps the person instead of the web constant (DarlingWebEndpoints.cs). Held back because that file is the busiest lane in the repo right now (Add linked triage artifact to alert deliveries (#2710, option 2) #2729's triage endpoint, Reject unknown keys in custom-view validation, naming the stray and the near miss (#2733) #2739's strict unknown-key validation), and the phase-1 gate needs none of it — the middleware is endpoint-group level, so it already covers endpoints those lanes added.
  • web.network.publicHost — one configured DNS name through the anti-DNS-rebind Host allowlist, plus the matching TLS SAN-warning adjustment. This is what makes Okta-class IdPs (which refuse bare-IP redirect URIs) usable; Entra works without it. Severable, and widening a rebind allowlist deserves its own review.

Everything below describes the combined change. It stays a draft until #2744 lands; then this rebases down to just the two items above.


DRAFT, deliberately. Erik's read — "rather far-fetched for a single pass PR" — is fair: this is ~2,180 added lines (571 of them tests) touching an auth surface. It sits here as a draft with the full live-IdP evidence below, structured so it can be reviewed in phases or carved into 2–3 smaller PRs at the seams marked below — say the word and I'll split it.

Implements #2550: per-user identity for the web dashboard, beside — never instead of — the shared token.


Verified against a live IdP (the centerpiece — read this before the diff)

Two independent live rigs, both fully reproducible on a laptop, both driving the real DarlingWebHostService — real config load, real Kestrel network bind, real middleware — not a test double:

Rig A — Keycloak 26.0 (real users, real passwords, real groups, confidential client):

docker run -d --name keycloak -p 8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0 start-dev

Provisioned via kcadm.sh: realm darling; confidential client darling-web (secret, redirect URI http://localhost:5199/auth/oidc/callback); a groups protocol mapper into the ID token (plain names); groups dba/eng; users erik-admin (dba), rita-viewer (eng), sam-nobody (no group), each with a password. Darling config: authority http://localhost:8081/realms/darling, clientSecret "env:DARLING_OIDC_SECRET" (exercising the reference path), roleClaim groups, adminRoles ["dba"], viewerRoles ["eng"]. The service ran on macOS via a 30-line gitignored harness hosting DarlingWebHostService directly (DOTNET_RUNNING_IN_CONTAINER=true for the #1804 container arm; store deliberately absent — no driven endpoint needs it).

Transcript (curl, full authorization-code + PKCE dance through Keycloak's real login form):

1. GET /                        -> 200 login page, SSO affordance present
2. erik-admin + real password   -> KC 302 back with code -> callback 200, session minted
   GET /api/session             -> {"can_edit":true,"subject":"erik-admin"}
   POST /api/views              -> 400 (PAST the seat gate; body validation refused - store absent by design)
3. rita-viewer                  -> {"can_edit":false,"subject":"rita-viewer"}
   POST /api/views              -> 403 {"error": "This account has read-only access."}
   POST /api/compose/run        -> 400 (past the gate - read-only-by-construction exception works)
   GET /api/ping                -> 200 {"status":"ok"}
4. sam-nobody (no group)        -> callback 403 "not granted a seat", session cookie minted: 0
5. erik-admin + WRONG password  -> Keycloak never redirects; Darling never sees a callback
6. tampered session cookie      -> 200 login page (treated as unauthenticated, not a 500)
7. forged ?code=&state= callback-> 400 "stale or was not started by this dashboard"
8. ?token=<shared token>        -> 302 strip + 2-segment cookie -> {"can_edit":true,"subject":null}
9. GET /auth/logout             -> cookie cleared, 302 /

And the service log carried the per-user audit trail:

OIDC sign-in: erik-admin as admin (edit) from ::1
OIDC sign-in: rita-viewer as viewer (read-only) from ::1
OIDC sign-in refused: sam-nobody@darling.test authenticated at the provider but matches
  neither adminRoles nor viewerRoles in claim 'groups'.
Web dashboard refused a request from ::1 (HTTP 403): the ReadOnlySeat gate rejected it
Web dashboard sign-out: erik-admin

OIDC-unconfigured pass (same binary, oidc block deleted): login page has zero SSO links and the token form; /auth/oidc/login is not squatted (200 login page); ?token= mints the 2-segment pre-#2550 cookie shape and /api/session answers {"can_edit":true,"subject":null} — the before-behavior, byte-shape included.

Rig B — ghcr.io/navikt/mock-oauth2-server (ran first, before the PR opened): same full dance as a PKCE-only public client (no secret) with claims injected at login — so both client registrations (confidential + public) have completed real handshakes.

What the repo's own defenses caught (and the fixes, each re-proven): CI's #2479 pins caught two defects in the first push — an un-described refusal gate, and sign-in refusals logging unthrottled on pre-credential endpoints (a log-flood a scanner could drive); fixed by routing them through the rate-limited refusal log under a new SignIn gate (f849e80). The review bot then caught four more real ones across two passes, all fixed: the default subject chain trusted an unverified email claim (identity theft by self-asserted address at a lax IdP — now gated on email_verified, 50f9402); the discovery document's issuer wasn't pinned to the configured authority (OIDC Discovery §4.3 — the ID-token iss check was validating against the discovery response's own claim; now refused on mismatch, 50f9402); a control-character open-redirect bypass in SanitizeRedirectPath (?return=%09%2F%2Fevil.com — browsers strip tab/CR/LF before URL parsing, so it became protocol-relative post-sign-in: a real phishing primitive; the guard now strips all C0 controls before collapsing, 3690e5b); and the discovery document's endpoints weren't held to the authority's scheme rule (a poisoned answer could point the secret-carrying token POST at cleartext http; now refused, 3690e5b). The Keycloak sign-in was re-run after the issuer/email fixes to prove the flow still completes.

Not covered live: a commercial tenant (Entra/Okta). The issue's own research already established the one behavioral difference that matters — redirect-URI policy — which publicHost exists to absorb.


What's in this PR, as phases — and where it splits

Phase Content Files Could stand alone?
1 — the security core web.network.oidc config + validation; the OIDC protocol layer (discovery, PKCE, code exchange, ID-token claim validation); the seat model + role mapping; the middleware composition (DecideWebRequest around the untouched DecideWebAuth); the group-level viewer write gate; the sign-in flow endpoints DarlingConfig.cs, Hosting/DarlingWebOidc.cs, Hosting/DarlingWebSeat.cs, Mcp/DarlingWebHostService.cs, Hosting/DarlingHttpRefusalLog.cs Yes — shared-token default untouched; without the later phases the feature works end-to-end, just with updated_by still web and no DNS-name hosts
2 — attribution /api/session reports subject + real can_edit; updated_by stamps the person DarlingWebEndpoints.cs (~33 lines) Yes — small, mechanical, rides on Phase 1's seat
3 — publicHost one DNS name through the anti-rebind Host allowlist + the TLS SAN-warning adjustment — what makes Okta-class IdPs (which refuse bare-IP redirect URIs) usable ~40 lines across DarlingConfig.cs / DarlingWebHostService.cs Yes — severable cleanly; without it the feature is Entra-shaped only

The SPA needed zero changes (the viewer seat lands on the existing /api/session can_edit contract the UI already fails closed on), so there is no SPA phase. The login page's SSO button is a const-string splice in the host service, part of Phase 1.

If splitting: Phase 1 first (it is 85% of the diff and all of the risk), then 2 and 3 as follow-ups — or 3 first as a tiny standalone if the Host-allowlist change deserves isolated scrutiny. Happy to restructure the branch either way.

How to review this (reading order, ~5 files carry all the weight)

  1. Hosting/DarlingWebOidc.cs (721 lines, ~half doc/comments) — the protocol, all pure statics. The one decision to interrogate first: the ID token's signature is not verified, and the class doc argues why that is spec-sanctioned for THIS channel only (OIDC Core §3.1.3.7 rule 6 — token received by direct client↔token-endpoint TLS; iss/aud/azp/exp/nonce still mandatory and checked). Second: DeriveTransactionKey — the derivation is load-bearing, not hygiene (below). Tests: DarlingWebOidcTests — PKCE pinned against RFC 7636 Appendix B's own vector; claim-validation matrix incl. multi-audience/azp and clock skew; config-validation matrix incl. http-loopback-only; the transaction round-trip.
  2. Hosting/DarlingWebSeat.cs (124 lines) — how the role survives the round-trip: the session cookie's Carry an authenticated subject in the web session cookie (#2550) #2583 signed subject slot carries o1:a:{subject} / o1:v:{subject}, so a viewer cannot promote themselves without breaking the HMAC; an unrecognized slot refuses the session. Plus IsRequestAllowed, the method-based write gate. Tests: seat codec incl. adversarial shapes (o1:v: inside a subject), the write-gate matrix incl. "a write endpoint added tomorrow is born gated".
  3. Mcp/DarlingWebHostService.cs (+562) — the composition. DecideWebAuth (the pinned pre-OIDC matrix) is untouched; DecideWebRequest wraps it: CIDR first (Forbid beats the OIDC endpoints), flow paths second, then the original matrix verbatim. Then HandleAuthFlowAsync: the transaction cookie is SameSite=Lax and the post-login landing is a script navigation, and both comments explain why anything else breaks only in real browsers (Strict is not sent on cross-site navigations). Tests: DarlingWebAuthTests — the DecideWebRequest rows, IsAuthFlowPath, publicHost allowlist rows, login-page fragment presence.
  4. The key-confusion pin (in DarlingWebOidcTests): both cookie shapes are {payload}.{HMAC} and the transaction cookie is minted for any unauthenticated in-CIDR caller — under one shared key it would validate as a session. TransactionCookie_PresentedAsSessionCookie_DoesNotValidate pins it closed, and the macOS harness runs the counterfactual (sign with the raw session key → it DOES validate) proving the derivation is what holds the door.
  5. DarlingConfig.cs (+149) — the WebOidcConfig shape; ResolveClientSecret is a copy of the ResolveToken DPAPI/env:/file: pattern; ValidateConfig refuses half-configured role mappings both ways. DarlingWebEndpoints.cs (+33) — the smallest and least risky: /api/session reads the seat from HttpContext.Items, updated_by stamps seat.EditorPrincipal.

Config keys, role mapping, back-compat (condensed)

All under web.network, file-defined, restart-only, JSONC-documented in darling.sample.json: oidc.authority (https; http toward loopback only), oidc.clientId (also the required ID-token audience — no separate knob), oidc.encryptedClientSecret/clientSecret (omit both = PKCE-only public client, warned), oidc.scopes (openid forced in), oidc.subjectClaim (default preferred_username→email→sub; when set, required — never a fallback), oidc.roleClaim+adminRoles/viewerRoles, and publicHost. Redirect URI: {scheme}://{host}:{port}/auth/oidc/callback, frozen into the transaction at login.

Role mapping: no mapping → every signed-in user edits (parity with the token, not a new privilege); mapped → admin ⇒ edit, viewer ⇒ read-only (server-enforced, UI already fails closed), neither ⇒ sign-in refused (per-user revocation working). Ordinal matching — role values are identifiers.

Back-compat: OIDC entirely off when unconfigured (live-verified byte-shape above); a misconfigured oidc block disables OIDC only, at Critical — never degrades the bind, because an IdP outage must not lock an operator out of their own monitoring; CIDR outermost; per-request Secure preserved; MCP untouched; the token stays as the scripted-caller/break-glass path.

Deliberately deferred

RP-initiated logout at the IdP; refresh tokens / a shorter OIDC-seat lifetime knob (12h cookie caps exposure; IdP revocation lands at next sign-in); --configure-network wizard awareness of the oidc block; SPA display of the signed-in subject (/api/session already returns it — left out to avoid wwwroot while #2710 is in flight).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg

erikdarlingdata and others added 2 commits August 31, 2026 20:11
Authorization code + PKCE against any standard OIDC provider, opt-in via
web.network.oidc and entirely off when unconfigured. The callback mints the
existing #2583 session cookie with the subject and role sealed in its signed
subject slot; the shared ?token= path is byte-for-byte unchanged as the
scripted-caller/break-glass credential. Role mapping (roleClaim +
adminRoles/viewerRoles) lands on the SPA's existing can_edit seat model, a
group-level middleware gate refuses every mutation from a viewer seat, and
updated_by stamps the signed-in person instead of the "web" constant.
web.network.publicHost admits one DNS name through the anti-rebind Host
allowlist for the IdPs (Okta) that refuse bare-IP redirect URIs.

Exercised end-to-end against a live OIDC provider in a container: full
code+PKCE handshake, admin and viewer mapping, denied seat, forged state,
token fallback, logout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
/// <summary>The subject-claim fallback chain when <c>subjectClaim</c> is unset, in order. Chosen for
/// attribution: <c>updated_by</c> stamped with an opaque <c>sub</c> GUID answers "who changed this
/// dashboard" no better than the <c>web</c> constant did.</summary>
internal static readonly string[] DefaultSubjectClaims = { "preferred_username", "email", "sub" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness/security: default subject chain trusts an unverified email claim.

ResolveSubject's default fallback chain (preferred_username → email → sub) accepts the email claim with no check on the sibling email_verified claim. For providers that allow self-asserted or unverified email addresses (generic OIDC providers, some B2C-style directories, or a tenant that hasn't enforced verification), an authenticated-but-unverified user could present an email claim matching a real operator's address.

This subject becomes the updated_by stamp on custom-view edits and the subject field /api/session reports — role/authorization is unaffected (that's decided separately via roleClaim), but the audit trail and "who is this" attribution this PR is explicitly built to provide (see the class/PR docs on attribution) could be corrupted or spoofed in the log/UI, not just merely inaccurate.

Suggest gating the email step in DefaultSubjectClaims on email_verified == true when that claim is present, or at minimum calling this out in the subjectClaim doc comment so operators know to set an explicit subjectClaim (e.g. preferred_username) when their IdP doesn't guarantee verified email.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 50f9402: the default chain's email step now requires email_verified (boolean true, or the string form some providers ship); an unverified email falls through to the opaque-but-honest sub. An operator who EXPLICITLY sets subjectClaim=email keeps that ungated - a stated trust decision, not a fallback. Pinned in ResolveSubject_DefaultChainEmail_RequiresEmailVerified; the macOS harness's old email-fallback fixture went red on the fix exactly as it should have.

return null;
}

var issuer = document["issuer"]?.GetValue<string>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor spec gap: discovery document's issuer isn't cross-checked against the configured authority.

OIDC Discovery 1.0 §4.3 requires the RP to verify "the Issuer value returned MUST be identical to the Issuer URL that was directly used to retrieve the configuration information" (i.e. document["issuer"] should equal Options.Authority, modulo trailing slash). Here the fetched issuer is trusted as-is and later used as the expectedIssuer passed into ValidateIdTokenClaims — so the ID-token iss check ends up validated against whatever the discovery response claims its own issuer to be, rather than being anchored to the operator's configured authority.

In practice this requires the discovery endpoint itself to be compromised or answer with a wrong issuer (TLS to the configured authority already anchors most of the trust here), so impact is low, but it's a one-line, spec-mandated check that's cheap to add:

if (!string.Equals(issuer!.TrimEnd('/'), Options.Authority.TrimEnd('/'), StringComparison.Ordinal)) { return null; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 50f9402: GetDiscoveryAsync refuses (and never caches) a document whose issuer is not the configured authority - ordinal, trailing slash tolerated per real-provider drift (OIDC Discovery 1.0 s4.3). Pinned in IssuerMatchesAuthority_Matrix, and the full code+PKCE sign-in re-proven against live Keycloak with the check in place.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review summary

Reviewed the full diff (2149 additions across DarlingWebOidc.cs, DarlingWebSeat.cs, DarlingWebHostService.cs, DarlingWebEndpoints.cs, DarlingConfig.cs, plus tests). This is Darling-only — Lite has no LAN-exposed web dashboard (its only HTTP server, Lite/Mcp/McpHostService.cs, is loopback-only/tokenless by design), so there's no Lite/Darling parity gap to flag here.

What holds up well:

  • The core protocol surface (DarlingWebOidc) is pure and thoroughly pinned: PKCE against the RFC 7636 Appendix B vector, ID-token claim validation (iss/aud+azp/exp/nonce) matching OIDC Core §3.1.3.7's direct-channel rule, and the transaction-cookie key derivation with a test proving the counterfactual (shared key would let a transaction cookie impersonate a session).
  • The CIDR-outermost / flow-path / original-matrix composition in DecideWebRequest is correct and doesn't regress the pre-existing DecideWebAuth matrix (kept untouched, composed around).
  • The group-level write gate (DarlingWebSeat.IsRequestAllowed) gates on HTTP method rather than enumerating routes, so a write endpoint added later is born gated — verified this reads correctly against every current route in DarlingWebEndpoints.cs.
  • Open-redirect guard (SanitizeRedirectPath) is applied to both the token-strip 302 and the OIDC return path; the post-sign-in landing page's script-navigation-instead-of-302 trick for the SameSite=Strict cookie is correct and well-explained.
  • Session cookie's two-shape (subjectless/subject-bearing) parse correctly signs the whole payload before the final dot, so tamper-evidence covers the subject too; malformed/oversized subjects refuse rather than truncate (avoiding identity collisions).

Two issues posted inline, both in DarlingWebOidc.cs:

  1. Moderate — the default subject-resolution chain (preferred_username → email → sub) trusts the email claim without checking email_verified, which can corrupt the updated_by audit trail / /api/session subject if the IdP allows unverified email. Doesn't affect role/authorization (that's separate via roleClaim), but does affect the attribution this feature is built to provide.
  2. Minor — the discovery document's issuer isn't cross-checked against the configured authority per OIDC Discovery §4.3, so the later ID-token iss check is anchored to whatever the discovery response claims rather than to operator config. Low practical impact given TLS to the configured authority, but a one-line spec-mandated check.

No T-SQL in this PR, so the collector-query conventions don't apply. Didn't find SQL injection, secret-logging, or CSRF gaps — the CSRF story (SameSite=Strict session cookie + Host allowlist + no CORS) and the Lax-but-scoped transaction cookie are both reasoned through correctly for the cross-site-callback case.

The repo's own #2479 pins caught two things in the first push: the new
ReadOnlySeat gate had no operator-actionable Describe entry, and the flow
handler's 404 backstop refused silently. Fixing the letter exposed the
category: every sign-in refusal was an unthrottled LogWarning on endpoints
that are reachable pre-credential by design - exactly the log-flood the
refusal limiter exists to bound. Sign-in refusals now go through
DarlingHttpRefusalLog under a new SignIn gate (with Describe entries for
both new gates); the one deliberate exception is the role-mapping denial,
which stays a direct line naming the subject because it is the audit trail
and costs the caller a completed IdP handshake. The unreachable 404
backstop now serves the login page - the same answer the non-flow pipeline
gives an unauthenticated caller - instead of a silent status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
@erikdarlingdata
erikdarlingdata marked this pull request as draft August 31, 2026 19:27
Comment on lines +619 to +635
if (JsonNode.Parse(body) is not JsonObject document)
{
return null;
}

var issuer = document["issuer"]?.GetValue<string>();
var authorizationEndpoint = document["authorization_endpoint"]?.GetValue<string>();
var tokenEndpoint = document["token_endpoint"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(issuer)
|| string.IsNullOrWhiteSpace(authorizationEndpoint)
|| string.IsNullOrWhiteSpace(tokenEndpoint))
{
return null;
}

_discovery = new DiscoveryDocument(issuer, authorizationEndpoint, tokenEndpoint);
return _discovery;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discovery document is trusted without pinning its issuer or endpoint schemes

GetDiscoveryAsync accepts whatever issuer, authorization_endpoint, and token_endpoint the discovery response contains, with no check that:

  • issuer equals the configured web.network.oidc.authority (OIDC Discovery §4.3: "The Issuer value returned MUST be identical to the Issuer URL that was directly used to retrieve the configuration information."), and
  • authorization_endpoint/token_endpoint are https:// — mirroring the scheme rule ValidateConfig already enforces on authority itself (loopback-only exception for http://).

This matters more than usual here because ValidateIdTokenClaims (called from the callback handler) compares the ID token's iss claim against this same discovery-reported issuer — not against the operator-configured authority. So the class doc's stated rationale for skipping signature verification ("TLS server validation MAY be used to validate the issuer in place of checking the token signature" — OIDC Core §3.1.3.7) is currently self-referential: nothing ties the validated iss back to a value the operator actually trusted/configured, it's only checked against whatever the discovery fetch itself returned.

Separately, if a discovery response ever returned a token_endpoint as http://... (misconfigured IdP, or a discovery response tampered with by something other than the trusted authority), the client secret and authorization code would be POSTed there in the clear — bypassing the loopback-only HTTP restriction that's deliberately placed on authority.

Worth pinning document["issuer"] == Options.Authority and requiring https:// (or loopback, matching the authority rule) on both endpoint URLs before caching the discovery document.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both halves fixed: the issuer pin in 50f9402 (see the thread above - it also repairs the self-referential-iss point, since the validated iss is now transitively the operator-configured authority), and the endpoint scheme rule in 3690e5b: authorization_endpoint and token_endpoint are held to the authority's own rule (https, or http toward loopback) before the document is cached, so a discovery answer cannot point the secret-carrying POST at cleartext. Pinned in IsAcceptableEndpointUrl_Matrix.


/* Where to land after the sign-in — the SPA path the login page was covering, run through the
same open-redirect guard as the token-strip 302. */
var returnPath = SanitizeRedirectPath(context.Request.Query["return"].ToString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open redirect via tab/newline bypass in SanitizeRedirectPath

SanitizeRedirectPath (used here for the attacker-controlled ?return= value, and again in WriteSignedInLandingAsync's location.replace(...)/<a href> around line 1550-1563) only strips leading / and \ characters. The WHATWG URL parser strips ASCII tab/CR/LF from anywhere in a URL string before parsing, though — and that stripping applies to location.replace(), <a href>, and HTTP Location headers alike.

A return value of \t//evil.com (e.g. GET /auth/oidc/login?return=%09%2F%2Fevil.com) passes through SanitizeRedirectPath almost unchanged — it becomes /\t//evil.com (the tab isn't a //\, so nothing strips it). When the browser later parses that string for the post-login navigation, it strips the embedded tab and is left with //evil.com — a protocol-relative URL — and navigates the just-authenticated user off-site.

No secret/token is needed to trigger this, just a crafted /auth/oidc/login?return=... link sent to a legitimate user. They complete a real sign-in with their own IdP, then land on an attacker-controlled page immediately after — a solid phishing primitive (e.g. a fake "session expired, re-enter your token" page). The existing SanitizeRedirectPath_ForcesSingleSlashSiteRelative test table (DarlingWebAuthTests.cs, guarding "security review M2") covers // and /\ but has no case for \t/\r/\n, so this variant slipped past the guard's own regression suite.

Suggest stripping ASCII tab/CR/LF from the whole string (not just checking leading chars) before/while collapsing leading slashes, then re-checking for a resulting //-prefixed result — mirroring what browsers actually do when they parse the string.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3690e5b - and thank you, this one was real: a genuine post-sign-in phishing primitive needing no credential. SanitizeRedirectPath now strips ALL C0 controls from the whole string before collapsing leading slashes, so the guard sees the same string the WHATWG parser will; deliberately broader than the spec's tab/CR/LF list, because enumerating exactly the browser's set is how the next variant gets through. The M2 table grows the tab/LF/CRLF rows plus a benign-control row, and the macOS harness runs the same transform against the shipped assembly.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the OIDC sign-in addition (DarlingWebOidc.cs, DarlingWebSeat.cs, DarlingWebHostService.cs, DarlingConfig.cs). This is entirely C#/Darling-only (no T-SQL, no counterpart in Lite — Lite has no web dashboard or OIDC-equivalent auth surface, so no parity concern there). The transaction-cookie key derivation, PKCE, nonce/state binding, claim validation, role-mapping fail-closed behavior, write-gate placement, and rate-limited refusal logging are all well-reasoned and backed by tests.

Left two inline findings:

  1. Open redirect in SanitizeRedirectPath (DarlingWebHostService.cs) — it only strips leading //\, but browsers strip embedded ASCII tab/CR/LF from a URL string before parsing it. A return= value like \t//evil.com slips through as /\t//evil.com and collapses to //evil.com (protocol-relative) once the browser parses it for location.replace(...) after a successful OIDC sign-in. This lets an attacker phish a legitimate user through a real sign-in and land them off-site afterward, no secret required — just a crafted /auth/oidc/login?return=... link. The existing "security review M2" test table for this function doesn't cover tab/CR/LF variants.

  2. Discovery document trust gap in DarlingWebOidc.GetDiscoveryAsync — the fetched issuer is never checked against the configured authority (OIDC Discovery §4.3 requires this), and authorization_endpoint/token_endpoint aren't required to be https:// the way authority itself is. Since the ID token's iss is later validated against this same discovery-reported issuer (not the configured authority), the class doc's stated rationale for skipping signature verification — "TLS validates the issuer" — isn't actually anchored to anything the operator configured.

Everything else (PKCE derivation, transaction/session key separation, claim/audience/nonce checks, subject-length refusal-not-truncation, role-mapping fail-closed-to-Denied, group-level write gate ordering ahead of routing, CIDR-outermost composition in DecideWebRequest) held up under review.

erikdarlingdata and others added 2 commits August 31, 2026 20:32
…covery issuer cross-check

Two claude[bot] findings, both real. (1) The default subject chain accepted
an email claim with no look at email_verified, so an IdP that lets users
self-assert an address would let an authenticated stranger present a real
operator's email and be stamped as them in updated_by and the audit log; the
default chain's email step now requires email_verified (boolean true or the
string form some providers ship), an unverified email falls through to the
opaque-but-honest sub, and an operator who explicitly sets
subjectClaim=email keeps their ungated choice. (2) The discovery document's
issuer was trusted as-is, so whatever answered the well-known URL chose the
issuer the ID-token iss check validated against - the one cross-input check
turned self-affirmation; per OIDC Discovery 1.0 s4.3 the issuer must now
equal the configured authority (ordinal, trailing slash tolerated) or the
document is refused and never cached. Both re-proven against live Keycloak:
the full code+PKCE sign-in still completes, role mapping intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
… scheme rule

Second review pass, two more real ones. (1) SanitizeRedirectPath stripped
only LEADING slashes, but the WHATWG URL parser removes tab/CR/LF from
anywhere in a URL before parsing - location.replace, <a href>, and Location
headers alike - so a crafted ?return=%09%2F%2Fevil.com survived the guard
and became protocol-relative in the browser: a phishing primitive that
lands a genuinely signed-in user on an attacker page. The guard now strips
all C0 controls from the whole string before collapsing, so it sees the
same string the browser will; the M2 test table grows the tab/CR/LF rows it
was missing. (2) The discovery document's authorization/token endpoints are
now held to the same scheme rule as the authority (https, or http toward
loopback) - the token endpoint receives the client secret and the code, and
the discovery response must not be able to point that POST at cleartext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Comment on lines +1545 to +1548
/* Per-user identity in the service log — the #2550 counterpart of the updated_by stamp. */
_logger.LogInformation(
"OIDC sign-in: {Subject} as {Role} from {Remote}",
subject, role == WebOidcRole.Admin ? "admin (edit)" : "viewer (read-only)", remote);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRLF log injection via unsanitized OIDC subject claim.

subject is resolved from ID-token claims the IdP (or, depending on IdP config, the end user's own profile fields) controls — preferred_username, email, sub, or an operator-configured subjectClaim. It's length-capped (MaxSubjectLength) but never stripped of control characters before it reaches the log, here and at two other spots that log it raw:

  • line 1354: _logger.LogInformation("Web dashboard sign-out: {Subject}", currentSeat.Subject);
  • lines 1536-1537: "OIDC sign-in refused: {Subject} authenticated at the provider but matches neither adminRoles nor viewerRoles..."
  • lines 1546-1548 (this one): "OIDC sign-in: {Subject} as {Role} from {Remote}"

This file already has DarlingHttpRefusalLog.Sanitize for exactly this class of input — its own doc comment says "a header carrying CR/LF would otherwise forge log lines" — and it's applied to the Host header and the IdP's error/error_description querystring values a few lines away in this same handler. The subject claim gets no such treatment, even though these three log lines are explicitly the audit trail this feature advertises ("Sign-ins, sign-outs and refusals are logged per user"). A subject value containing CR/LF could forge fake audit-log entries (e.g. splice in a fabricated "signed in as admin" line), which undermines the very audit trail #2550 is adding.

Suggest running subject (and currentSeat.Subject at the logout site) through DarlingHttpRefusalLog.Sanitize before it hits any of these three log lines.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cd760f7, at the category rather than the three symptoms: the sign-in now REFUSES a subject containing any ASCII control character (SubjectCarriesControlCharacters, same decision shape as the length cap - no legitimate identifier carries one, and laundering a hostile name then attributing writes to the laundered form is worse than refusing the seat), so nothing downstream - these log lines, updated_by, or a consumer added later - can ever receive one. The three audit lines additionally run the subject through Sanitize as defense in depth, at the 256-char subject width so attribution is not truncated to the Host-header echo budget. Pinned in SubjectCarriesControlCharacters_Matrix, including the non-ASCII-name row proving only CONTROLS are refused.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (DarlingConfig.cs, DarlingWebEndpoints.cs, DarlingWebOidc.cs, DarlingWebSeat.cs, DarlingHttpRefusalLog.cs, DarlingWebHostService.cs, darling.sample.json, and the two test files).

Lite/Darling parity: N/A here — Lite is a standalone WPF desktop app with no web dashboard/HTTP surface, so there's no counterpart for OIDC sign-in to drift out of sync with. No parity concern.

T-SQL style: N/A — no .sql files touched.

Overall: this is unusually careful work for an auth surface — the protocol logic (PKCE, ID-token claim validation, issuer/audience/azp/nonce checks, discovery-document issuer pinning, discovery-endpoint scheme enforcement) is all pure and pinned by ~540 lines of unit tests with no live IdP required, the two cookie shapes (session vs. transaction) are deliberately keyed apart so one can't be replayed as the other, the read-only write gate is fail-closed by method rather than by route enumeration (so a future write endpoint is "born gated"), and the two rounds of self-review already caught and fixed real issues (email_verified gate on the default subject chain, discovery issuer cross-check, control-char open-redirect bypass via the WHATWG URL parser's tab/CR/LF stripping, discovery endpoint scheme rule). The shared-token path is untouched by construction (DecideWebRequest composes around the existing DecideWebAuth, not through it).

One finding posted inline: the resolved OIDC subject claim is logged raw (sign-in, sign-in-refused, and sign-out lines) without going through the DarlingHttpRefusalLog.Sanitize helper this same file already uses for the Host header and the IdP's error/error_description — so a subject value with embedded CR/LF could forge audit-log lines, which is notable here specifically because these lines are the audit trail the PR advertises.

No missing-index DMV concerns (not applicable — no T-SQL in this diff), no other correctness/perf issues found.

… lines

Third review pass: a subject claim carrying CR/LF could forge lines in the
very audit trail #2550 adds. Fixed at the category, not the symptom: the
sign-in now REFUSES a subject containing any ASCII control character - the
same decision as the length cap, because no legitimate identifier carries
one and laundering a hostile name and then attributing writes to the
laundered form is worse than refusing the seat - and the three audit log
lines (sign-in, sign-out, role denial) additionally run the subject through
the existing sanitizer as defense in depth, at the full 256-char subject
width so attribution is not truncated to the Host-header echo budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Phase 1 is carved out into #2744 — non-draft, based on current dev (post #2738-#2742), and the one to review now: the OIDC protocol layer, config + validation, the seat model and claim-to-role mapping, and the group-level read-only write gate, with the shared-token default untouched when OIDC is unconfigured.

Re-verified against a live Keycloak on that branch, including the property this split makes load-bearing: #2729's /api/triage endpoint is gated correctly (GET 200, POST 403 for a viewer seat) with no wiring in that lane — which is the whole argument for group-level middleware over per-endpoint attributes.

This draft keeps the two severable pieces: the per-user audit attribution in DarlingWebEndpoints.cs (/api/session subject + real can_edit, and the updated_by stamp), and web.network.publicHost (one DNS name through the Host allowlist — what Okta-class IdPs need, since Entra accepts bare IPs). Both are written and on this branch; it rebases down to just them once #2744 lands.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Claude posting for Erik Darling

Closing: this branch is a divergent second implementation of work that already landed on dev, and every hardening fix it carries is present there.

DarlingWebOidc.cs reached dev via 9da00121e ("Add OIDC token validation and claim-to-role mapping for the web dashboard") plus review fix 1e087e1fe. This branch does not contain 9da00121e — merge-base --is-ancestor is false — so the two are independent implementations of #2550 rather than one building on the other. This one is 672 commits behind dev and git merge-tree conflicts in its own core files (DarlingWebOidc.cs, DarlingWebOidcTests.cs, DarlingWebAuthTests.cs, DarlingConfig.cs, DarlingWebHostService.cs, darling.sample.json). A merge would be a rewrite.

I checked each of this branch's four review-fix commits against dev before closing, because abandoning a branch that carries security hardening the survivor lacks is the expensive mistake here. All four are covered:

this branch's fix on dev
50f94027a email_verified gate on the default subject chain DarlingWebOidc.cs:395 — if (email is not null && IsEmailVerified(payload)), pinned by ResolveSubject_DefaultChainEmail_RequiresEmailVerified, including the case where a provider ships email_verified as the string "true"
50f94027a discovery issuer cross-check DarlingWebOidc.cs:615 — OIDC Discovery §4.3, with the reasoning that otherwise a well-known URL "could name ANY issuer and the ID-token iss comparison would dutifully validate"
3690e5b43 control-char open-redirect bypass SanitizeRedirectPath strips every control character (c >= ' ' && c != '\x7f'), then strips leading / and \\ repeatedly, then forces one leading /. Applied twice — on the way in at DarlingWebHostService.cs:1378 and again on use at :1567
cd760f7b3 refuse control-character subjects, sanitize audit lines same character filter, same path

dev's version also closes something worth naming: DeriveTransactionKey separates the transaction-cookie key from the session key, because both cookies are {payload}.{base64url(HMAC)} and the transaction cookie is minted for any unauthenticated in-CIDR caller — under one shared key that caller could paste a transaction value into the session slot and have the JSON validate as an authenticated session. Pinned.

So closing costs nothing. #2550 itself stays open — see my comment there; the role mapping landed but the identity half did not.

Closing by hand; closing keywords do not fire here.

pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Sep 10, 2026
…oard (erikdarlingdata#2550)

The security-bearing core of per-user identity, carved out of the larger
erikdarlingdata#2730 draft so it can be reviewed on its own: authorization code + PKCE
against any standard OIDC provider, opt-in via web.network.oidc and
entirely off when unconfigured, with the shared token untouched as the
scripted-caller/break-glass credential.

ID tokens are validated for issuer, audience + azp, expiry with clock skew,
and the nonce binding them to the transaction this host started; the
discovery document's issuer must equal the configured authority and its
endpoints must be https (or loopback), so a discovery response can neither
choose the issuer the iss check validates against nor point the
secret-bearing token POST at cleartext. The authenticated subject and its
mapped role ride the existing erikdarlingdata#2583 signed cookie slot, so a viewer cannot
promote themselves without breaking the HMAC.

Role mapping lands on two seats - edit, or read-only - with an unmapped
user refused outright. The read-only seat is enforced by a group-level
middleware gate ahead of routing rather than per-endpoint attributes, so
endpoints added by parallel lanes are covered with no coordination:
verified live against erikdarlingdata#2729's triage endpoint, GET 200 and POST 403 for a
viewer, with nothing in that lane aware this exists.

Deliberately NOT here, and following in erikdarlingdata#2730: the /api/session subject and
the updated_by attribution stamp (per-user audit), and the configured DNS
name for the Host allowlist. Consequence to know while this stands alone:
/api/session still reports can_edit: true for every seat, so a viewer sees
edit affordances that then correctly 403 - a UI staleness, not a gap in
enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant