Skip to content

OIDC sign-in for the web dashboard: give the web surface a per-user identity instead of one shared token #2550

Closed

Description

@erikdarlingdata

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 HttpOnly SameSite=Strict session 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_by stamp 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_by becomes a real subject instead of the constant web, 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 DarlingWebAuthTests would 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's web.network block alongside the token, or the store's config_service so 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

  1. erikdarlingdata commented on Aug 23, 2026

    @erikdarlingdata
    OwnerAuthor

    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.

  2. erikdarlingdata commented on Aug 24, 2026

    @erikdarlingdata
    OwnerAuthor

    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 UseHttps returning 0 was the blocker. #2562 shipped (merged as #2568): web.network.tls takes 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 an https:// 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:

    • the cookie's Secure flag 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 hardcoding Secure.
    • the certificate needs an iPAddress SAN for the listen IP, since the anti-rebind Host allowlist accepts only localhost, a loopback literal, or that exact IP. A redirect_uri will 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:634 calls the web constant 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. DarlingWebAuthTests grows 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's web.network block, and the deciding argument is the client secret: it needs the same DPAPI treatment encryptedToken already gets, and the store's config_service is 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. Note darling.json is 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_by consuming 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 (EphemeralKeySet fails 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.

  3. added a commit that references this issue on Aug 24, 2026
  4. erikdarlingdata commented on Aug 25, 2026

    @erikdarlingdata
    OwnerAuthor

    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-universal http://localhost exception. If that is the case here, the options are:

    1. Give the dashboard a DNS name — which then has to be added to the Host allowlist AND to the certificate's SAN, so it is a change to Add an HTTPS listener for the web dashboard — prerequisite for OIDC, and the shared token currently crosses the LAN in the clear #2562's shape as well as this one.
    2. OIDC on loopback only — which this issue already called "largely pointless", since network mode is the mode that needs per-user identity.
    3. Something else entirely, if the IdP turns out to be flexible.

    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-server starts in one command and serves a conformant discovery document advertising authorization_endpoint, token_endpoint, jwks_uri and code_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 curl does 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.com is 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 longer sub fails the sign-in loudly instead of silently merging two identities.

    HTTPS shipped in #2568, and the per-request Secure cookie flag from that work has to be preserved: one host serves TLS on the network listener and plain HTTP on loopback simultaneously, so a hardcoded Secure breaks 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's web.network block because the client secret needs the same DPAPI treatment encryptedToken already 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 (EphemeralKeySet fails 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.

  5. erikdarlingdata commented on Aug 27, 2026

    @erikdarlingdata
    OwnerAuthor

    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 #2562 iPAddress SAN certificate permit in network mode. It varies by provider, and that is the answer:

    • Microsoft Entra documents its restrictions explicitly — https scheme (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 literal https://127.0.0.1 over localhost "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.
    • Okta states no rule either way in its redirect-URI documentation, and community reports consistently say IP-based redirect URIs fail in the flow and a real domain is required.

    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 HttpOnly SameSite=Strict cookie, 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.

  6. erikdarlingdata commented on Aug 27, 2026

    @erikdarlingdata
    OwnerAuthor

    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's iPAddress SAN certificate permit in network mode.

    Microsoft Entra: permitted. Its restrictions are documented and exhaustive — https scheme (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 literal https://127.0.0.1 over localhost "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:

    1. The Host allowlist accepts a configured DNS name alongside localhost / loopback / the exact listen IP.
    2. The TLS certificate carries that name, not only the iPAddress SAN 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://localhost is 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 HttpOnly SameSite=Strict cookie, 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.

  7. erikdarlingdata commented on Aug 31, 2026

    @erikdarlingdata
    OwnerAuthor

    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.publicHost admits 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.oidc with the client secret on the same DPAPI/env:/file: footing as encryptedToken; 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-request Secure preserved; MCP untouched. Role mapping lands on the SPA's existing can_edit seat (zero SPA changes) plus a server-side group-level write gate, and updated_by finally 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 DarlingWebHostService on a real network bind completed the full code+PKCE handshake against mock-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.

  8. erikdarlingdata commented on Sep 7, 2026

    @erikdarlingdata
    OwnerAuthor

    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.

    9da00121e shipped 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 on dev, checked fix by fix). What that delivered against the four benefits listed above:

    this issue asked for state on dev
    Read-only seats — map IdP groups to a role Done. Two seats (edit / read-only), roleClaim with adminRoles/viewerRoles, and a validation refusing half a mapping because "half a mapping is worse than none: roleClaim alone reads as roles are being checked"
    Per-user revocation Done — falls out of the IdP being the authority; an unmapped authenticated user gets nothing
    MFA / conditional access Done — inherited from the IdP
    Identity — updated_by becomes a real subject instead of the constant web NOT done

    DarlingWebEndpoints.cs:757 still reads:

    The updated_by stamp for a web edit. The web surface has no per-user identity, so a constant is honest — it marks the row as web-authored.

    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 the email_verified gate), the updated_by column 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:

    1. What updated_by carries when OIDC is NOT configured. The shared-token path still exists and is still the default; those callers have no subject. Keeping the literal web for 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.
    2. Whether the MCP stamp changes too. DarlingWebEndpoints.cs:761 documents 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.

  9. erikdarlingdata commented on Sep 7, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    Closed by #3151, merged to dev at 7f2054255609bff0983e3ddd031fd737e3415568. Closing by hand — closing keywords do not fire here, since merges land on dev while the default branch is main.

    The identity half is in, which was this issue's first-named consequence and the one thing the earlier OIDC work left behind. updated_by now 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.EditorPrincipal and FromContext were both unit-tested and inert while the middleware's own comment named /api/session and "the updated_by stamps" as its consumers. Both write sites and /api/session now actually read it. No schema change was needed and that was verified rather than assumed: the column is unbounded nullable text, and an over-long subject gets a 403 refusal rather than truncation.

    Two decisions, both argued rather than defaulted. updated_by stays one heterogeneous column — sometimes a subject, sometimes the web provenance 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 on WebEditorPrincipal — the expected identifier is a suffix of the token it should reject, so reverting a write site left every assertion green.
    • The live round-trip passed "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 updatedBy is a string, so a positional swap with description compiles and silently stamps the description into the identity column. Named arguments at all four sites make that CS8323 — a build error instead of a defect waiting for a pin to notice.

    One thing fixed rather than filed: an honest can_edit: false made 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions