Repository navigation
OIDC sign-in for the web dashboard: give the web surface a per-user identity instead of one shared token #2550
Description
The web dashboard authenticates with one shared bearer token and has no notion of who is looking at it. That is fine for a single operator on a laptop and increasingly wrong for the way it is actually being used — a network-exposed dashboard several people open, with write paths behind it.
What exists today
DarlingWebHostService.DecideWebAuth is a pure function over three facts, and the matrix is pinned in DarlingWebAuthTests:
- Network mode — a caller presents
?token=(constant-time compare), which is exchanged for an HMAC-signed HttpOnlySameSite=Strictsession cookie and 302-redirected to strip the token from the URL. Out-of-CIDR is 403 even with a valid cookie and token. No credential gets a minimal inline login form. - Loopback still requires a cookie or token (DECISION NEEDED: web dashboard's tokenless-loopback allow now covers write endpoints #1649) — it is not a free pass. A loopback-only dashboard registers no auth middleware at all and is tokenless.
- Always on: a Host-header allowlist (anti-DNS-rebind) and
SameSite=Strict.
That is a well-built gate, and none of it is what this issue proposes to remove. The problem is what the credential is.
Three concrete consequences
1. There is no identity, and the code says so. DarlingWebEndpoints.cs:634:
The
updated_bystamp for a web edit. The web surface has no per-user identity, so a constant is honest — it marks the row as web-authored.
Every custom view anyone creates or edits is stamped web. The provenance column distinguishes web from MCP, which is real information, but it cannot answer "who changed this dashboard" because nothing in the request carries that.
2. The shared token gates writes, not just reads. Any authenticated seat can create, update and delete custom views, and the alert-tuning surface changes configuration the service delivers on. The comment at DarlingWebEndpoints.cs:156 is explicit that editing is deliberately available to any authenticated seat, on the reasoning that a network caller has already passed the token→cookie + CIDR gate. That reasoning is sound given one operator. With one shared secret and several people, it means no way to grant read-only access to anyone.
3. The token is, by design, readable by anyone who can log on to the host. From DarlingPrintEndpointTokenTests, in the repo's own words: it is a DPAPI LocalMachine-scope blob with an entropy constant compiled into an open-source binary, and darling.json deliberately grants INTERACTIVE read — so "an ordinary logged-on NON-administrator can already read and decrypt the blob in four lines of PowerShell." That is an accepted and correctly-documented property of the endpoint tokens. It is a much less comfortable property for the only credential standing between a LAN client and the dashboard's write paths.
And revocation is all-or-nothing: taking access away from one person means rotating the token and re-onboarding everyone, which is exactly the failure #2479 added --print-web-token to stop people hitting by accident.
What OIDC buys
- Identity —
updated_bybecomes a real subject instead of the constantweb, with no schema change (the column already exists and already carries a provenance string). - Per-user revocation — deprovision in the IdP, done. No shared-secret rotation, no re-onboarding.
- Read-only seats — map IdP groups to a role, and gate the custom-view CRUD and alert-tuning writes on it. This is the first time the product could offer "let the team look, let two people change things."
- MFA and conditional access for free, from whatever the org already runs.
The prerequisite that probably splits this in two
The web listener is plain HTTP. grep -c UseHttps across Darling/ returns 0; DarlingWebHostService calls options.Listen(...) with no TLS configuration at all.
That matters because most IdPs refuse a non-HTTPS redirect_uri — with the near-universal exception of http://localhost, which is exactly the loopback case and therefore the only place OIDC would work as things stand. Network mode, which is the mode that actually needs OIDC, is the mode that cannot have it yet.
So this likely wants to be two issues: an HTTPS listener (cert source, config shape, what happens on renewal, whether it is opt-in), and then OIDC on top. Worth deciding before any of it is designed, because if TLS is not on the table then this issue is loopback-only and largely pointless.
Open questions, none of which should be answered by me alone
- Does the shared token stay as a fallback for headless/scripted callers and the loopback case, with OIDC as an opt-in for network mode? Leaning yes — dropping it would break anyone scripting against the web endpoints, and the tokenless-loopback path is a deliberate ergonomic choice. But two credential paths into one surface is exactly where auth bugs live, so the matrix in
DarlingWebAuthTestswould need to grow to cover it rather than being bolted around. - Which flow — authorization code + PKCE is the obvious answer for a browser app.
- Where does client config live —
darling.json'sweb.networkblock alongside the token, or the store'sconfig_serviceso it is editable without touching the box? - Does the CIDR gate stay in front of OIDC? I would keep it. Defence in depth, and it is the thing that currently makes an out-of-CIDR request 403 even holding a valid credential.
- MCP is unaffected — it has its own separate network block and token, and this issue does not propose changing it.
Related: #2479 (endpoint token reprint, the same shared-secret ergonomics), #1649 (loopback still requires a credential), #1562 (the current browser auth model).
Activity
erikdarlingdata commentedon Aug 23, 2026
Unblocked, conditionally. Erik's call 2026-08-23: the product gets an HTTPS listener, filed as #2562. That was the hard prerequisite named above — most IdPs refuse a non-HTTPS
redirect_uri, so without it OIDC could only ever have worked on loopback, the one mode that does not need it.#2562 should land before any work starts here. Nothing in this issue's design changes as a result; it simply becomes buildable in the mode that matters.
erikdarlingdata commentedon Aug 24, 2026
Prerequisite cleared, and the load-bearing piece is now in review. Answering my own open questions so the wiring is not waiting on me.
The prerequisite is gone
grep -c UseHttpsreturning 0 was the blocker. #2562 shipped (merged as #2568):web.network.tlstakes a PKCS#12 bundle or a PEM pair, applies to the network listener only, fails closed on a missing/expired/ambiguous certificate, and warns for 30 days before expiry. Network mode — the mode that actually needs OIDC — can now present anhttps://redirect_uri.Two things that came out of building it are worth carrying into this issue, because both were found only by running the real thing:
Secureflag is now per-request (context.Request.IsHttps), because one host serves TLS on the network listener and plain HTTP on loopback simultaneously. Any OIDC work has to keep that shape rather than hardcodingSecure.localhost, a loopback literal, or that exact IP. Aredirect_uriwill therefore be built on that IP, not a DNS name — which some IdPs restrict. Worth checking against whichever provider gets used first.The blocker nobody had named: the cookie could not carry an identity
Before writing any OIDC wiring I checked what happens after a successful sign-in, and the answer is that it evaporates. The session cookie is
{expiryUnix}.{base64url(HMAC)}— an expiry and nothing else. The subject established at sign-in is discarded at the post-exchange 302 and every request after it arrives anonymous.So this issue's first consequence has a mechanical cause, not a policy one.
DarlingWebEndpoints.cs:634calls thewebconstant honest because "the web surface has no per-user identity" — correct, and fixing the write path would not have helped, because nothing in the request carried a name to write.#2583 adds the second cookie shape,
{expiryUnix}.{base64url(subject)}.{HMAC}, with the HMAC covering the whole prefix before the final dot. That last detail is the whole point: signing only the expiry and parking the subject beside it leaves identity unauthenticated, so any holder of a valid cookie could rewrite the subject and be served as anyone — worse than the shared token, because it would look like identity while providing none. Pinned both ways (rewritten subject, and a signature grafted from another principal's genuine cookie), plus the shape-confusion cases, executed against the shipped assembly rather than a copy of it.Landed separately because it is provable without an identity provider, which the wiring is not. Same split this issue proposed for TLS.
Answering the open questions
Does the shared token stay? Yes, as the fallback for scripted callers and the loopback ergonomic, with OIDC opt-in for network mode. The concern about two credential paths is right and is exactly why #2583 makes the shared-token seat report null rather than an empty subject — the two paths are distinguishable at the point every downstream decision is made, instead of one impersonating the other.
DarlingWebAuthTestsgrows the matrix rather than getting bolted around.Which flow? Authorization code + PKCE, confidential client. It is a server-side ASP.NET Core app, so it can hold a secret, and PKCE costs nothing on top.
Where does client config live?
darling.json'sweb.networkblock, and the deciding argument is the client secret: it needs the same DPAPI treatmentencryptedTokenalready gets, and the store'sconfig_serviceis not a secret store. Issuer, client id and scopes go beside it for the same reason the token does — the host has to bind before the store is necessarily reachable. Notedarling.jsonis JSONC, so this is a text insertion, not a ConvertFrom/ConvertTo round trip.Does the CIDR gate stay in front? Yes. Defence in depth, and it is the thing that makes an out-of-CIDR request 403 while holding a valid credential. It stays outermost.
MCP unaffected. Confirmed — separate network block, separate token, not touched.
What is left on this issue
The wiring: the OIDC handler, the config model with the DPAPI secret, the grown auth matrix, role mapping for read-only seats, and
updated_byconsuming the subject.One thing I will not do quietly: this cannot ship on the strength of reading it. The TLS work was wrong in two ways that only a live handshake exposed (
EphemeralKeySetfails every handshake on Windows and is refused outright on macOS; both loaders silently dropped the intermediate chain, measured as 2 certificates on disk and 1 on the wire). An auth path deserves at least that much, so the wiring gets exercised against a real IdP in a container before it goes near a release.Merge pull request #2583 from erikdarlingdata/feature/2550-session-co…
erikdarlingdata commentedon Aug 25, 2026
Parked pending a real IdP to test against. Writing down the sequencing so it is not rediscovered, and naming the one check that should happen BEFORE any code is written.
The blocking question: will the IdP accept our redirect_uri shape?
This is not a detail — it can invalidate the design as specified.
The anti-DNS-rebind Host allowlist accepts only
localhost, a loopback literal, or the exact configured listen IP. And the TLS work (#2562) established that the certificate must carry an iPAddress SAN for that IP, because a DNS-name certificate can never match a Host header the allowlist permits.So a network-mode redirect URI is a bare IP:
https://10.x.x.x:5153/signin-oidc.Many identity providers refuse IP-based redirect URIs, permitting only
https://with a hostname, plus the near-universalhttp://localhostexception. If that is the case here, the options are:Fifteen minutes in a free developer tenant settles it. Doing it first avoids designing around an assumption that may not hold.
How this gets tested, in the order to do it
1. Local IdP in Docker — protocol correctness, and this is where the bugs are. Verified this works:
ghcr.io/navikt/mock-oauth2-serverstarts in one command and serves a conformant discovery document advertisingauthorization_endpoint,token_endpoint,jwks_uriandcode_challenge_methods_supported: [plain, S256]. Keycloak is the heavier alternative and behaves more like a commercial IdP.This tier exercises discovery, auth-code + PKCE, token exchange, JWKS signature validation and claim mapping — and it can run in CI, which matters because none of the above should be manually re-verified on every change.
Honest limit on what I verified: I confirmed the provider runs and its discovery document is right. I did NOT complete a headless code exchange — that mock server presents a login interaction which scripted
curldoes not satisfy. That is a property of a shell-based harness, not of the provider; the real client drives a browser. Whoever builds this should expect to do the flow through a browser or configure the provider's non-interactive mode.2. A free Okta developer tenant — vendor validation.
developer.okta.comis self-serve and requires no corporate integration. It is real Okta, so it is where vendor-specific behaviour appears: redirect-URI rules (see above), group-claim shape, token lifetimes, refresh rotation.3. A corporate tenant — not needed for this, and wiring a project into one to test a feature is a larger conversation than the feature warrants. It would also make the test unreproducible for anyone else working on the repo.
What is already done and does not need redoing
The session cookie carries an authenticated subject as of #2583 —
{expiryUnix}.{base64url(subject)}.{HMAC}, with the signature covering the whole prefix before the final dot, so a rewritten subject and a signature grafted from another principal both fail. That was the actual blocker: before it, a subject established at sign-in was discarded at the post-exchange redirect and every later request arrived anonymous. The subject cap is 256 characters and it refuses rather than truncates, so an IdP issuing a longersubfails the sign-in loudly instead of silently merging two identities.HTTPS shipped in #2568, and the per-request
Securecookie flag from that work has to be preserved: one host serves TLS on the network listener and plain HTTP on loopback simultaneously, so a hardcodedSecurebreaks one of them.The decisions are settled (see the earlier comment): shared token stays as the scripted-caller fallback, authorization code + PKCE with a confidential client, config in
darling.json'sweb.networkblock because the client secret needs the same DPAPI treatmentencryptedTokenalready gets, CIDR gate stays outermost, MCP untouched.State
Parked. Not blocked on design — blocked on a testable IdP, and I will not ship an auth path that has never completed a real handshake. The TLS work is the precedent: it was wrong in two ways that only a live handshake exposed (
EphemeralKeySetfails every handshake on Windows and is refused outright on macOS; both loaders silently dropped the intermediate chain, measured as 2 certificates on disk and 1 on the wire). An auth path deserves at least that.erikdarlingdata commentedon Aug 27, 2026
Closing. Erik's call 2026-08-27: the shared-token model is adequate for the web dashboard, and OIDC is not worth what it would cost to make work everywhere.
Recording the research first, because it answers the question this was parked on and would otherwise be re-derived.
The blocker was whether an identity provider will accept a bare-IP
redirect_uri—https://10.x.x.x:5153/signin-oidc— which is the only shape the anti-DNS-rebind Host allowlist and the #2562iPAddressSAN certificate permit in network mode. It varies by provider, and that is the answer:httpsscheme (localhost excepted), case-sensitive, no special characters, no IDN, no wildcards — and none of them require a DNS name. It goes further and recommends the IP literalhttps://127.0.0.1overlocalhost"to prevent your app from breaking due to misconfigured firewalls or renamed network interfaces". A private IP with a custom port satisfies every stated rule.So the feature would have worked for some customers and silently not for others, discovered at the redirect rather than at configuration. Making it universal meant first teaching the Host allowlist and the TLS certificate to accept a configured DNS name — a second prerequisite on top of #2562, for a feature whose value is per-user identity on a dashboard that is already CIDR-gated and cookie-authenticated.
What stands today, and why it is defensible:
?token=is a constant-time compare exchanged for an HMAC-signed HttpOnlySameSite=Strictcookie, the token is stripped from the URL by a 302, out-of-CIDR is 403 even with a valid cookie AND token, and loopback still requires one (#1649). What it does not give is who — so an operator wanting per-user attribution should put the dashboard behind their own reverse proxy and authenticate there.Reopen if the shape of the deployment changes — a hostname-addressed dashboard makes this cheap, and #2562's TLS work is the half that would have been hard.
Sources: Entra redirect URI restrictions, Okta redirect URI configuration.
erikdarlingdata commentedon Aug 27, 2026
The parked question is answered: it varies by identity provider, and that is itself the finding. Staying parked, but no longer on an unknown.
This was waiting on whether an IdP will accept a bare-IP
redirect_uri—https://10.x.x.x:5153/signin-oidc— which is the only shape the anti-DNS-rebind Host allowlist and #2562'siPAddressSAN certificate permit in network mode.Microsoft Entra: permitted. Its restrictions are documented and exhaustive —
httpsscheme (localhost excepted), case-sensitive, no special characters! $ ' ( ) , ;, no internationalized domain names, no wildcards — and none of them require a DNS name. The documentation goes further and recommends the IP literalhttps://127.0.0.1overlocalhost"to prevent your app from breaking due to misconfigured firewalls or renamed network interfaces". A private IP with a custom port satisfies every rule stated.Okta: no. Its redirect-URI documentation states no rule either way, and community reports are consistent that an IP-based redirect URI fails in the flow and a real domain is required.
So building against the bare IP would ship a feature that works for some customers and silently does not for others, discovered at the redirect rather than at configuration time. That is worse than not shipping it, which is why this stays parked rather than becoming buildable.
What it would take to be universal
A DNS-name path, which is two changes and neither is in this issue's current scope:
localhost/ loopback / the exact listen IP.iPAddressSAN Add an HTTPS listener for the web dashboard — prerequisite for OIDC, and the shared token currently crosses the LAN in the clear #2562 established.Both are tractable; neither is free, and together they are a prerequisite rather than a detail.
Worth stating for anyone who arrives here wanting per-user identity
Loopback mode would work today —
http://localhostis the near-universal IdP exception that even Okta honours — but loopback is the single-operator case, which is the one that needs per-user identity least.What ships now is defensible on its own terms:
?token=is a constant-time compare exchanged for an HMAC-signed HttpOnlySameSite=Strictcookie, the token is stripped from the URL by a 302, out-of-CIDR is 403 even with a valid cookie and token, and loopback still requires one (#1649). What it does not give is who. An operator who needs that today should put the dashboard behind their own reverse proxy and authenticate there.Sources: Entra redirect URI restrictions, Okta redirect URI configuration.
erikdarlingdata commentedon Aug 31, 2026
Implementation up: #2730. Unparked by Erik's call to build it; the parked blocker is absorbed rather than dodged.
The blocker was the redirect-URI shape: the Host allowlist and #2562's iPAddress-SAN certificate forced a bare-IP
redirect_uri, which Entra accepts and Okta refuses. #2730 adds the DNS-name half this thread said universality required:web.network.publicHostadmits exactly one configured name through the anti-rebind allowlist (whole-name match, never a suffix), and the TLS SAN advice recognizes a certificate carrying that name. Bare-IP deployments still work for the providers that allow them; Okta-class providers get a name.Everything else landed as this thread designed it: authorization code + PKCE, confidential client (secret optional -> PKCE-only public client); config in
web.network.oidcwith the client secret on the same DPAPI/env:/file:footing asencryptedToken; the #2583 cookie carrying subject AND role inside the signed slot; the shared token unchanged as the scripted-caller/break-glass path; CIDR outermost, OIDC endpoints included; per-requestSecurepreserved; MCP untouched. Role mapping lands on the SPA's existingcan_editseat (zero SPA changes) plus a server-side group-level write gate, andupdated_byfinally names a person.The bar set here — "exercised against a real IdP in a container before it goes near a release" — was met before the PR opened: the real
DarlingWebHostServiceon a real network bind completed the full code+PKCE handshake againstmock-oauth2-server, both role mappings, the denied seat, forged state, token fallback, and logout. Details and the honest limits (Windows suites run in CI; no commercial-tenant handshake) are in the PR body.Address review: refuse control-character subjects, sanitize the audit…
Merge pull request #2744 from erikdarlingdata/feat/2550-oidc-core
erikdarlingdata commentedon Sep 7, 2026
Claude posting for Erik Darling
The authentication and authorization halves landed. The identity half did not — and identity is this issue's first named consequence.
9da00121eshipped OIDC token validation and claim-to-role mapping (draft PR #2730 was an independent second implementation and is now closed; every hardening fix it carried is present ondev, checked fix by fix). What that delivered against the four benefits listed above:devroleClaimwithadminRoles/viewerRoles, and a validation refusing half a mapping because "half a mapping is worse than none:roleClaimalone reads as roles are being checked"updated_bybecomes a real subject instead of the constantwebDarlingWebEndpoints.cs:757still reads:That is verbatim the comment this issue quoted as consequence #1. Every custom view anyone creates or edits over the web is still stamped
web, so the surface still cannot answer who changed this dashboard — which was the opening complaint.The remaining work is small, well-defined, and needs no schema change, exactly as this issue predicted: the subject is now resolved and available (
ResolveSubject, with the default chain and theemail_verifiedgate), theupdated_bycolumn already exists and already carries a provenance string, and the only thing missing is threading the authenticated subject through to the stamp.Two things want deciding rather than assuming, and neither should be settled by an implementer alone:
updated_bycarries when OIDC is NOT configured. The shared-token path still exists and is still the default; those callers have no subject. Keeping the literalwebfor them is the obvious answer, but it means the column is now heterogeneous — sometimes a subject, sometimes a provenance marker — and anything reading it has to cope. The alternative is a separate column, which contradicts this issue's own "no schema change" claim.DarlingWebEndpoints.cs:761documents a sibling constant for MCP-authored views. MCP has its own network block and token and this issue explicitly does not propose changing it — so leaving it alone is right, but the asymmetry should be deliberate and written down rather than incidental.Rescoping this issue to that residual, since the rest is delivered. The title still describes the whole feature; the work left is the identity stamp.
This is the shape worth flagging for its own sake: the half that shipped is the half with no user-visible surface, and the half that did not is the one the reporter would notice. Nothing was wrong with what landed — it just is not what closes this.
erikdarlingdata commentedon Sep 7, 2026
Claude posting for Erik Darling
Closed by #3151, merged to
devat7f2054255609bff0983e3ddd031fd737e3415568. Closing by hand — closing keywords do not fire here, since merges land ondevwhile the default branch ismain.The identity half is in, which was this issue's first-named consequence and the one thing the earlier OIDC work left behind.
updated_bynow carries the authenticated subject;DarlingWebEndpoints.cs:757's "the web surface has no per-user identity" is gone.The seam already existed and had no production caller.
DarlingWebSeat.EditorPrincipalandFromContextwere both unit-tested and inert while the middleware's own comment named/api/sessionand "the updated_by stamps" as its consumers. Both write sites and/api/sessionnow actually read it. No schema change was needed and that was verified rather than assumed: the column is unbounded nullabletext, and an over-long subject gets a 403 refusal rather than truncation.Two decisions, both argued rather than defaulted.
updated_bystays one heterogeneous column — sometimes a subject, sometimes thewebprovenance marker — because its sole consumer is a byline, nothing filters, sorts or groups on it, and splitting it would make every reader join two columns to rebuild a string it already displays. And MCP keeps its constant, since it has no sign-in flow and therefore no subject to prefer; that asymmetry is now pinned and explained so it survives someone "finishing the job".Three of the guards were worthless until mutation exposed them, and none would have been found by reading:
Contains("EditorPrincipal")passed onWebEditorPrincipal— the expected identifier is a suffix of the token it should reject, so reverting a write site left every assertion green."web"and asserted"web", which a store that drops the argument entirely also satisfies.Contains("probe_failed")was satisfied by that mechanism's own doc comment.And one route only enumeration finds, closed at the compiler rather than by a test. Every argument around
updatedByis astring, so a positional swap withdescriptioncompiles and silently stamps the description into the identity column. Named arguments at all four sites make thatCS8323— a build error instead of a defect waiting for a pin to notice.One thing fixed rather than filed: an honest
can_edit: falsemade a UI path reachable that only described a failed probe, so an OIDC viewer was told to "reload to edit" when their role never permits it. That was caused by this change, so it belongs in this change.Still deferred, and named: MCP has no per-user identity. That is the one genuine remainder.
Add OIDC token validation and claim-to-role mapping for the web dashb…