Skip to content

Host-only kbase_session; read kbase_session_backup as a fallback - #53

Merged
dauglyon merged 8 commits into
mainfrom
feat/session-backup-cookie
Sep 24, 2026
Merged

dauglyon merged 8 commits into
mainfrom
feat/session-backup-cookie

Conversation

@dauglyon

@dauglyon dauglyon commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Production kbase-ui keeps kbase_session on .narrative.kbase.us (ui/src/features/auth/hooks.ts:41-46, ui/config.json), and the Narrative keeps it on its own host (narrative/kbase-extension/static/kbase/js/api/auth.js:12-21). Neither cookie reaches gen2.kbase.us. Both apps also write the token to kbase_session_backup on .kbase.us. Before this change, next-gen-ui wrote its kbase_session on .kbase.us and never read the backup.

Host-only kbase_session

A kbase_session on .kbase.us reaches narrative.kbase.us as a second cookie of that name, and the readers there take different ones:

Reader Takes Code
kbase-ui the first ui/src/common/cookie.ts:3-13
Narrative client the last narrative/.../api/auth.js:90-97
Narrative server one, via self.cookies.get authhandlers.py:52

The cookie now has no Domain attribute unless COOKIE_DOMAIN sets one. Cookies earlier builds wrote on .kbase.us are left to expire, up to 14 days.

kbase_session_backup

Event kbase_session backup
Read preferred fallback
/api/V2/me answers 401 cleared cleared if it holds the rejected token; a different backup token is validated next
Sign-out cleared cleared if it holds the revoked token and the revoke succeeded
Root gate rejects the token (no 2FA) cleared kept
Value is a malformed percent escape that cookie only that cookie only
  • The backup is never written.
  • A 401 used to leave a dead kbase_session in place.
  • A session read from the backup is kbase-ui's token. Signing out revokes it, which also ends kbase-ui, Narrative and Lakehouse (BERDL hub) sessions using it.
  • If the backup holds a different live 2FA token after sign-out, that session is signed in here.
  • The backup's name is the runtime setting BACKUP_COOKIE_NAME, default kbase_session_backup. Every kbase.us host receives every .kbase.us cookie, so environments tell their backups apart by name, as kbase-ui does (REACT_APP_KBASE_BACKUP_COOKIE_NAME). The name is matched literally.
  • Deletes name this host's parent domain: gen2.kbase.us gives .kbase.us.

Cross-tab signals

A token switch sends other tabs one set: or cleared: for the outcome, never an intermediate cleared:. This covers:

  • /me falling back from kbase_session to the backup;
  • eviction of a corrupt kbase_session;
  • a backup check that throws. Other tabs get set: and re-check.

2FA

A session whose token did not use 2FA (mfa !== 'Used') is not signed in anywhere in the app. The root gate already enforced this. The login page did not: it disabled the ORCID button for such a session while every gated route sent the user back to /login. useSignedInMe applies the gate's rule, and the login page and its ?redirect check use it. The gallery shows the username of any session /me accepts.

The cached tokenInfo is keyed by token. It was keyed without one, so after /me switched to the backup token, the gate read the old token's 2FA answer.

Rancher

  • Remove COOKIE_DOMAIN from the gen2 workload. It is set to kbase.us, which keeps the old .kbase.us cookie.
  • A deployment on a non-production auth service sets BACKUP_COOKIE_NAME.

Checks

npm run lint, npm run format:check, npm run typecheck, npm run build, npx vitest run --dir src: exit 0, 236 tests.

cookie.kbase.test.ts runs on a https://gen2.kbase.us origin so parent-domain cookies apply. Each fix was reverted in turn and its test failed.

The entrypoint renders BACKUP_COOKIE_NAME when set and leaves the placeholder, read as the default, when unset.

Production kbase-ui scopes kbase_session to .narrative.kbase.us and the
Narrative scopes it to its own host, so a user signed in there reached
other kbase.us hosts signed out. Both also write kbase_session_backup on
.kbase.us; getToken now falls back to it.

The backup is deleted only when its token is dead everywhere: after the
revoke at sign-out, and when /api/V2/me answers 401 (which now also
clears kbase_session; it previously stayed). The root gate's MFA
rejection leaves it, since other kbase.us sites still accept that token.
kbase-ui and the Narrative each keep kbase_session on their own host. One
written on .kbase.us reaches them as a second cookie of the same name,
and they resolve the pair differently (kbase-ui takes the first, the
Narrative's client the last), so they can act on different tokens. It
also reaches ci, appdev and next, which run other auth services.

The Domain attribute is now omitted unless COOKIE_DOMAIN sets one. The
.kbase.us copy earlier builds wrote is expired on every write and clear,
and migrateSharedCookie moves a session held only there onto this host
at boot, using the expiry mirrored at sign-in.
@dauglyon dauglyon changed the title Read kbase_session_backup as a fallback session cookie Host-only kbase_session; read kbase_session_backup as a fallback Sep 24, 2026
The deletion hit any kbase_session on .kbase.us, whoever set it. Cookies
this app wrote there under earlier builds now run to their expiry.
kbase_session_backup is shared with other kbase.us sites. A /me 401 or a
sign-out now deletes it only when it holds the token that died, and a
sign-out whose revoke failed leaves it. After a 401 on kbase_session, a
different backup token is validated before the user is signed out.

The login page used any session /me accepts as signed in, so a session
without 2FA disabled the ORCID button while the root gate rejected it.
useSignedInMe applies the gate's rule (MFA Used) there. The gallery still
shows any session's username.
Every kbase.us host receives every .kbase.us cookie, so environments tell
their session backups apart by name. BACKUP_COOKIE_NAME (default
kbase_session_backup) sets which one is read; its delete names this
host's parent domain.

The cached tokenInfo was keyed without the token, so after /me fell back
from a dead kbase_session to the backup, the gate read the old token's
2FA answer for the new one. It is now keyed by token.

That fallback also broadcast `cleared:` to other tabs before signing in
with the backup, leaving them signed out. Other tabs now hear only the
outcome.
The cookie name is operator-set and went into a regex unescaped, so a
name with `.` matched other cookies and one with `(` threw.

A corrupt kbase_session told other tabs `cleared:` even when the backup
then signed this tab in; it now says so only when no backup is left. A
backup check that throws after kbase_session was cleared told no one;
other tabs are now asked to re-check.
@dauglyon
dauglyon merged commit 41e3449 into main Sep 24, 2026
6 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