Skip to content

Renew OIDC sessions instead of re-authenticating the browser - #131

Merged
artyomsv merged 1 commit into
masterfrom
fix/refresh
Sep 10, 2026
Merged

artyomsv merged 1 commit into
masterfrom
fix/refresh

Conversation

@artyomsv

Copy link
Copy Markdown
Owner

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, 777 typed into Max changed files and left alone. Page instrumentation recorded:

Time (UTC) Event
15:47:38 … 15:50:41 field value = 777 (13 consecutive polls)
15:50:56.107–.159 GET /api/me499, three times
15:50:56.170–.280 GET /gw/auth/login499, GET /wk/auth/login499
15:50:56.554 pagehide — the window navigates
15:51:02 new document, same route, field empty

All 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-168 predicted it would.

Each close handler asked /api/me why and was answered 499. fetchMe maps a non-ok response to null, and needsLogin(null) is false by design, 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.

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 in spire-ui/src calls it).

Why it shipped

docs/D10-AUTH-PLAN.md:344-346 listed 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-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. That is what the guard checks.

OidcSessionsAreRenewedTest derives its service list by scanning for application.yml files 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-arch declares the scanned files as task inputs — without that, turning the setting back off would report a cached pass.

Verification

./gradlew testFast testServices   ->  BUILD SUCCESSFUL

Mutation-verified, per CLAUDE.md:

Mutation Result
refresh-expired: false in the gateway 51 tests ran, 1 failedOidcSessionsAreRenewedTest
session-age-extension: 0M in the gateway 51 tests ran, 1 failed — the same one

What 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:

  1. That refresh-expired applies under application-type: hybrid. Quarkus scopes the option to ApplicationType#WEB_APP; all four services are hybrid. 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.
  2. That the session cookie still fits. Renewal requires the refresh token in the cookie, and the phase-0 spike measured the cookie already chunked with two roles and no custom claims.

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 as SMOKE-TEST.md Mode J check 11.

Security note

This is a deliberate loosening, stated in docs/SECURITY.md rather 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.

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.
@artyomsv
artyomsv merged commit 787c4b3 into master Sep 10, 2026
14 checks passed
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.

1 participant