Renew OIDC sessions instead of re-authenticating the browser - #131
Merged
Merged
Conversation
The dashboard reloaded itself every five minutes and discarded whatever the operator had typed into a form. Reproduced on the dev stack on 2026-09-10 and traced end to end. Keycloak's default ID-token lifespan is five minutes, and no service enabled token refresh, so at every expiry Quarkus invalidated the local session and auto-closed all of the dashboard's WebSockets. Each close handler asked /api/me why and was answered 499; fetchMe maps a non-ok response to null, and needsLogin(null) is false, so the branch written for "no session at all" was never taken. The expiry fell through into ensureServiceSessions, whose sibling probes were refused too, and that assigned window.location to a login. The provider's own session was still alive, so it re-authenticated with no prompt and returned to the same screen -- a page that reloaded itself on a timer, said nothing about why, and lost unsaved input. Set token.refresh-expired, token.refresh-token-time-skew and authentication.session-age-extension on all four OIDC services. All three are needed together: refresh-expired is inert without a non-zero session-age-extension, so a half-applied change reads exactly like an applied one and behaves exactly like none. OidcSessionsAreRenewedTest derives the service list by scanning for application.yml files that declare an OIDC client, so a fifth deployable that forgets renewal fails the build; a second test pins the four known ones so the rule cannot pass vacuously. Both mutations were verified to fail exactly one test. spire-arch declares the scanned files as task inputs, without which turning the setting back off would report a cached pass. This is a deliberate loosening -- an active session goes up to eight hours without a challenge, bounded by the realm's SSO Session Max -- and it is recorded in SECURITY.md rather than left implicit. The realm's idle timeout still ends an abandoned session and logout still ends all of them. Two things remain open and are registered in UNVERIFIED.md: the fix has not been watched working on a live stack, and refresh-expired is documented for application-type WEB_APP while these services are hybrid. No test here can observe a token reaching its exp. The design note also specifies a UI guard so that a session which lapses for real does not take the window away from unsaved work. That half is not built.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The symptom
The dashboard reloaded itself roughly every five minutes. It came back on the same screen, so nothing on screen said anything had happened — but every unsaved form field was empty.
Reported as: "code-spire UI refreshes automatically, it happened few times already when I was editing some forms and lost the data."
Reproduced, not guessed
Dev stack, authentication on,
#/settings/general,777typed into Max changed files and left alone. Page instrumentation recorded:777(13 consecutive polls)GET /api/me→ 499, three timesGET /gw/auth/login→ 499,GET /wk/auth/login→ 499pagehide— the window navigatesAll three services logged the same expiry, at the same second, every five minutes:
15:23:56,15:28:56,15:33:56,15:38:56,15:43:56,15:48:56,15:50:56.Root cause
Keycloak's default ID-token lifespan is five minutes, and no service enabled token refresh. At every expiry Quarkus invalidated the local session and auto-closed all of the dashboard's WebSockets — exactly as
docs/D10-AUTH-PLAN.md:167-168predicted it would.Each close handler asked
/api/mewhy and was answered499.fetchMemaps a non-ok response tonull, andneedsLogin(null)isfalseby design, so the branch written for "no session at all" was never taken. The expiry fell through intoensureServiceSessions, whose sibling probes were refused too, and that assignedwindow.locationto a login. The provider's own session was still alive, so it re-authenticated with no prompt and returned to the same screen.The trigger was the sibling-session probe, not the dashboard's own session check. That is an accident: the code path written to handle a lost session is unreachable for the case it was written for.
Ruled out: Vite HMR (no reload followed an HMR message), a service restart (none in the logs), and
location.reload()(nothing inspire-ui/srccalls it).Why it shipped
docs/D10-AUTH-PLAN.md:344-346listed the open item: "Configure refresh/session-age extension against the ~5-minute default … Or record 'expiry only, no logout in v1' in the ADR." Logout was wired; the session-lifetime half was neither configured nor recorded. The item read as closed while one of its two halves had happened.The change
token.refresh-expired,token.refresh-token-time-skewandauthentication.session-age-extensionon all four OIDC services.All three are needed together —
refresh-expiredis inert without a non-zerosession-age-extension, so a half-applied change reads exactly like an applied one and behaves exactly like none. That is what the guard checks.OidcSessionsAreRenewedTestderives its service list by scanning forapplication.ymlfiles declaring an OIDC client, so a fifth deployable that forgets renewal fails the build; a second test pins the four known ones so the rule cannot pass vacuously.spire-archdeclares the scanned files as task inputs — without that, turning the setting back off would report a cached pass.Verification
Mutation-verified, per
CLAUDE.md:refresh-expired: falsein the gatewayOidcSessionsAreRenewedTestsession-age-extension: 0Min the gatewayWhat is NOT proven — please read before merging
The fix has not been watched working on a live stack. The dev containers on the machine this was written on belong to a different worktree (
code-spire-worktrees/feat-software-factory), and rebuilding them would have swapped somebody else's running environment onto this branch.Two claims are asserted by nothing here, and both are registered in
docs/UNVERIFIED.md§B:refresh-expiredapplies underapplication-type: hybrid. Quarkus scopes the option toApplicationType#WEB_APP; all four services arehybrid. The web-app leg should honour it — but this project has twice shipped a setting that read as applied and was not, both times behind a green build.Pass looks like: dev stack up with authentication on, a value typed into a Settings form, the tab left alone for 15 minutes, and afterwards the value still present and
docker logs spire-orchestrator-dev | grep "no longer valid"showing no new line in that window. Proposed asSMOKE-TEST.mdMode J check 11.Security note
This is a deliberate loosening, stated in
docs/SECURITY.mdrather than left implicit: an active session goes up to eight hours without a challenge, bounded by the realm's SSO Session Max. The realm's SSO Session Idle still ends an abandoned session, and logout still ends every session including the provider's. Quarkus ships the option off and says an admin-level decision may be required — this is that decision, and it is reversible in four lines.Deliberately not in this PR
The design note specifies a UI guard so a session that lapses for real — idle timeout, provider restart, revoked session — raises a banner instead of taking the window away from unsaved work. That half is not built, by choice. This PR makes the reload rare rather than every five minutes; it does not make it non-destructive.
Full analysis:
docs/superpowers/specs/2026-09-10-session-renewal-and-unsaved-work-design.md.