Repository navigation
Add OIDC sign-in for the web dashboard (#2550) - #2730
erikdarlingdata wants to merge 6 commits into
Conversation
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
# Conflicts: # CHANGELOG.md
| /// <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" }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>(); |
There was a problem hiding this comment.
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; }There was a problem hiding this comment.
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.
Review summaryReviewed the full diff (2149 additions across What holds up well:
Two issues posted inline, both in
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
| 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; |
There was a problem hiding this comment.
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:
issuerequals the configuredweb.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."), andauthorization_endpoint/token_endpointarehttps://— mirroring the scheme ruleValidateConfigalready enforces onauthorityitself (loopback-only exception forhttp://).
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.
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Reviewed the OIDC sign-in addition ( Left two inline findings:
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 |
…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
| /* 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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 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 ( One finding posted inline: the resolved OIDC 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
|
Phase 1 is carved out into #2744 — non-draft, based on current Re-verified against a live Keycloak on that branch, including the property this split makes load-bearing: #2729's This draft keeps the two severable pieces: the per-user audit attribution in |
|
Claude posting for Erik Darling Closing: this branch is a divergent second implementation of work that already landed on
I checked each of this branch's four review-fix commits against
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. |
…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
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):
Provisioned via
kcadm.sh: realmdarling; confidential clientdarling-web(secret, redirect URIhttp://localhost:5199/auth/oidc/callback); agroupsprotocol mapper into the ID token (plain names); groupsdba/eng; userserik-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 hostingDarlingWebHostServicedirectly (DOTNET_RUNNING_IN_CONTAINER=truefor 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):
And the service log carried the per-user audit trail:
OIDC-unconfigured pass (same binary,
oidcblock deleted): login page has zero SSO links and the token form;/auth/oidc/loginis not squatted (200 login page);?token=mints the 2-segment pre-#2550 cookie shape and/api/sessionanswers{"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
SignIngate (f849e80). The review bot then caught four more real ones across two passes, all fixed: the default subject chain trusted an unverifiedemailclaim (identity theft by self-asserted address at a lax IdP — now gated onemail_verified, 50f9402); the discovery document's issuer wasn't pinned to the configured authority (OIDC Discovery §4.3 — the ID-tokenisscheck was validating against the discovery response's own claim; now refused on mismatch, 50f9402); a control-character open-redirect bypass inSanitizeRedirectPath(?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
publicHostexists to absorb.What's in this PR, as phases — and where it splits
web.network.oidcconfig + validation; the OIDC protocol layer (discovery, PKCE, code exchange, ID-token claim validation); the seat model + role mapping; the middleware composition (DecideWebRequestaround the untouchedDecideWebAuth); the group-level viewer write gate; the sign-in flow endpointsDarlingConfig.cs,Hosting/DarlingWebOidc.cs,Hosting/DarlingWebSeat.cs,Mcp/DarlingWebHostService.cs,Hosting/DarlingHttpRefusalLog.csupdated_bystillweband no DNS-name hosts/api/sessionreportssubject+ realcan_edit;updated_bystamps the personDarlingWebEndpoints.cs(~33 lines)publicHostDarlingConfig.cs/DarlingWebHostService.csThe SPA needed zero changes (the viewer seat lands on the existing
/api/sessioncan_editcontract 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)
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.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 carrieso1:a:{subject}/o1:v:{subject}, so a viewer cannot promote themselves without breaking the HMAC; an unrecognized slot refuses the session. PlusIsRequestAllowed, 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".Mcp/DarlingWebHostService.cs(+562) — the composition.DecideWebAuth(the pinned pre-OIDC matrix) is untouched;DecideWebRequestwraps it: CIDR first (Forbid beats the OIDC endpoints), flow paths second, then the original matrix verbatim. ThenHandleAuthFlowAsync: the transaction cookie isSameSite=Laxand 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— theDecideWebRequestrows,IsAuthFlowPath, publicHost allowlist rows, login-page fragment presence.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_DoesNotValidatepins 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.DarlingConfig.cs(+149) — theWebOidcConfigshape;ResolveClientSecretis a copy of theResolveTokenDPAPI/env:/file:pattern;ValidateConfigrefuses half-configured role mappings both ways.DarlingWebEndpoints.cs(+33) — the smallest and least risky:/api/sessionreads the seat fromHttpContext.Items,updated_bystampsseat.EditorPrincipal.Config keys, role mapping, back-compat (condensed)
All under
web.network, file-defined, restart-only, JSONC-documented indarling.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(defaultpreferred_username→email→sub; when set, required — never a fallback),oidc.roleClaim+adminRoles/viewerRoles, andpublicHost. 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
oidcblock 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-requestSecurepreserved; 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-networkwizard awareness of theoidcblock; SPA display of the signed-in subject (/api/sessionalready returns it — left out to avoidwwwrootwhile #2710 is in flight).🤖 Generated with Claude Code
https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg