Skip to content

fix(dashboard): match OIDC introspection identity on username, not sub (#1571) - #1605

Merged
Xore merged 6 commits into
mainfrom
fix/oidc-introspect-username-not-sub-v2
Aug 17, 2026
Merged

fix(dashboard): match OIDC introspection identity on username, not sub (#1571)#1605
Xore merged 6 commits into
mainfrom
fix/oidc-introspect-username-not-sub-v2

Conversation

@Xore

@Xore Xore commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Supersedes #1599, rebased onto main after #1604 (CI matrix parallelization) merged, so this PR's own CI runs the new parallel Go/Scripts-and-Compose jobs.

The fix (from #1599)

Root cause of #1571 (dashboard fetch()s intermittently 401 despite a valid session): identityFromRequest()'s 30-second introspection re-check compared the response's sub claim against the session's subject. This deployment's Keycloak never populates sub on access tokens (confirmed live), so result.Subject was always "" and every session failed its very first 30s re-check unconditionally — deterministic, not intermittent, which is what made it look random from the browser (Keycloak's own SSO cookie silently re-authenticated on the next full page load, masking it).

Fix: match on username instead (also RFC 7662 OPTIONAL, but reliably populated by this deployment).

Also included

  • The chaos test (scripts/test-dashboard-oidc-chaos.sh) had a scenario asserting a graceful Keycloak restart forces re-login — that assumption was itself wrong (real Postgres-backed sessions survive a graceful restart; verified live against real Keycloak). Corrected to assert the actual behavior, plus a new scenario for genuine admin-triggered session revocation, preserving real security coverage.
  • The e2e suite's fake OIDC mock only ever echoed sub, so it hit the exact same bug class once the browser-matrix test run got past 30s — updated to echo username from the same fixture constant the seeded session uses, so they can't drift apart again.
  • playwright.config.ts flipped to fullyParallel: true + a fixed CI worker count — the previous fullyParallel: false meant the whole 333-test suite only ever ran on one worker, which is exactly what let the 30s-aging bug hide in fast local runs. Verified 333/333 pass repeatably with 4 workers.

Verified: go build, go vet, go test ./... all clean. Full Playwright suite: 333/333 passing.

Fixes #1571

Xore and others added 6 commits August 17, 2026 20:48
)

Caught live via dashboard/Keycloak logs on the dev box: auth.xore.rocks's
introspection responses for the apiary-dashboard client never carry a
"sub" claim (confirmed both in the raw access-token JWT itself and in the
introspection endpoint's own response -- RFC 7662 lists "sub" as
OPTIONAL, and this realm/client combination only puts it on ID tokens).
identityFromRequest()'s introspect() call compared the response's "sub"
against the session's subject, so result.Subject was always "" and every
session failed its very first 30-second re-check unconditionally --
not an intermittent race at all, just gated on request timing: any
session that made an API call >=30s after login (or its last check) hit
this every single time, then silently "healed" on the next full
navigation because Keycloak's own SSO cookie re-authenticated invisibly,
looking exactly like #1571's "reload fixes it with no re-login" report.

Live evidence: hp-dashboard/hp-dashboard-b logs showed hundreds of
"introspection reported inactive" rejections for the same subject, every
one at age/lastValidated ~30-60s (the first post-login re-check), with
no preceding refresh attempt -- ruling out a stale/expired token. Manually
introspecting a live, seconds-old access token pulled from the shared
Valkey session store confirmed active:true but no "sub" field, while
"username" was present and correct.

"username" is also RFC 7662 OPTIONAL but is what this deployment reliably
returns, so introspect() now matches on that instead. Existing tests
updated to mirror the real (sub-less) response shape; new tests lock in
that a sub-less-but-active response is accepted and that a genuine
username mismatch is still rejected and the session still deleted.

go build ./..., go vet ./..., and go test -race ./... (minus the
pre-existing, unrelated flaky race in TestSyntheticSensorsReachDashboardSnapshot,
reproduced identically on main without this change) all pass.

Fixes #1571

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The public-repository safety check flags any reference to this
deployment's real domain in the public repo. Reword the comment to
describe the same fact without naming it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…start

Scenario 1b (real Keycloak restart) started failing after #1599's
username-vs-sub introspection fix, expecting a 303 re-login redirect but
getting 200. Investigated with direct evidence: querying Keycloak's own
introspection endpoint right after `docker restart` (a graceful SIGTERM
stop, Postgres backing store left running) shows the session genuinely
survives -- a clean, authoritative "active":true for the pre-restart
access token, reproduced across multiple runs. This deployment's Keycloak
26.7.1 reloads SSO session state from its DB on startup; the old "restart
wipes all session state" assumption baked into this test's comments was
wrong for this restart shape. Before #1599, this scenario's 303 only
"passed" because the sub-matching bug rejected every 30s re-check
unconditionally (#1571) regardless of what Keycloak actually said -- not
because the restart itself invalidated anything.

Updated scenario 1b to assert the session survives (200), matching
Keycloak's genuinely-authoritative answer and production's own
Postgres-backed topology. Added scenario 1c using Keycloak's admin
logout-user REST API for a real, deterministic session revocation
(confirmed live to flip introspection to "active":false immediately,
independent of restart/persistence internals) so the "dashboard cleanly
303s when Keycloak says the session is gone" coverage 1b used to
(incorrectly) provide isn't lost.

Verified: go build/vet/test all green, and the real-Keycloak chaos script
passes deterministically across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ction match

#1599 changed identityFromRequest()'s 30s re-check to match the
introspection response's "username" instead of "sub" (this
deployment's Keycloak never populates "sub" on access tokens). The
e2e suite's fake OIDC issuer only ever echoed back "sub", so every
fixture session got rejected on its first re-check once the browser
matrix ran long enough to cross 30s -- exactly the same bug class,
now hitting the test fixture instead of production. Echo "username"
instead, sourced from the same FIXTURE_USERNAME constant the fixture
session is seeded with, so the two can't drift apart again.

Also flip playwright.config.ts to fullyParallel + a fixed CI worker
count -- fullyParallel: false meant the whole 333-test single spec
file only ever ran on one worker regardless of --workers, which is
what let the 30s-aging bug go unnoticed in the first place (fast
parallel runs never kept a session alive that long). Verified this
doesn't introduce cross-test interference: 333/333 pass repeatably
with 4 workers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@Xore
Xore merged commit e4a089d into main Aug 17, 2026
88 checks passed
@Xore
Xore deleted the fix/oidc-introspect-username-not-sub-v2 branch August 17, 2026 22:09
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.

Dashboard API fetch()s intermittently 401 'authentication required' despite a valid session

1 participant