Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/per-user-active-org-and-session-user-purge-5664.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'@object-ui/auth': patch
'@object-ui/console': patch
---

The active organization id is now stored per user, and a change of session user drops the
previous user's client state wholesale (objectui#5664).

`auth-active-organization-id` was a single un-namespaced `localStorage` key while its
siblings were already user-scoped (`objectui-recent-items:u:`, `objectui-favorites:u:`,
`flow-palette-recents:u:`). On a browser handed from one account to another — a shared
machine, a kiosk, a handover, a support session — the arriving user's console read the
PREVIOUS user's organization id. The header workspace chip rendered the previous user's
workspace for a user whose `organization/list` was empty, and the consequence past the
cosmetics is the one worth stating: the polluted org context suppressed
`RequireOrganization`'s routing into the guided "Create your workspace" first-run flow, so
a brand-new user on that browser silently never got the new-user flow at all.

Nothing about row visibility rode on this. With the stale id the server answers
`403 USER_IS_NOT_A_MEMBER` on `get-full-organization` and `set-active`, and lists zero
environments; the damage was entirely in what the client believed about itself.

Three changes, and the third is the one that closes the class rather than the instance:

- The key is per-user (`auth-active-organization-id:u:$userId`), matching the convention
its siblings already use.
- It can no longer be written un-namespaced at all. Where no session user is known yet the
value lives in memory for that page-load only — a namespacing that kept a bare-key
fallback would re-open the defect the first time a write happened before the user id
resolved.
- **A change of session user drops the previous user's client state wholesale.** This is an
allowlist sweep of both `localStorage` and `sessionStorage`, not a list of known keys, so
the NEXT storage key someone adds without a `:u:` scope is covered before it is written.
Only device-scoped entries survive: the arriving session's own bearer token, the pointer
recording whose state the browser holds, and the UI theme.

Both properties objectui#5703 established are preserved and still pinned: `get()` prefers a
non-null `localStorage` read and falls back to the in-memory value, and the memory value is
nulled BEFORE storage is touched — by `clear()` as before, and now by the user-change purge
too, so the outgoing user's org id cannot outlive their persisted key on the sign-out-then-
sign-in path that never reloads the page.

Existing browsers are not migrated. A value sitting under the retired bare key is
unattributable — nothing recorded whose org id it is — so migrating it is precisely the
defect it would be migrating away from, and it is deleted instead. A signed-in user loses
nothing durable: the active organization is a server-owned fact that
`AuthProvider.refreshOrganizations` re-asks for whenever the list is non-empty and no
active org is held, including the ADR-0081 single-membership repair. One boot re-supplies
it; users with no organization land on the guided first-run flow, which is the outcome this
card is about.

`apps/console`'s pre-render auth preflight purges every spelling of the active-org key —
the retired bare one and each `:u:` scope — when it finds a dead bearer token, and
deliberately leaves the session-user pointer in place so the next sign-in can still tell
that the browser changed hands.
44 changes: 39 additions & 5 deletions apps/console/src/lib/auth-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,50 @@
*
* Run BEFORE React renders. If `auth-session-token` is present, probe
* `/api/v1/auth/get-session` with that Bearer (and no cookie). If the
* response is not authenticated, delete the stale token + the stale
* `auth-active-organization-id` so AuthProvider's first `getSession()`
* falls back to cookie auth cleanly.
* response is not authenticated, delete the stale token + every stale
* active-organization id (the bare pre-objectui#5664 key and each
* `auth-active-organization-id:u:` scope) so AuthProvider's first
* `getSession()` falls back to cookie auth cleanly.
*
* Idempotent, runs once per page load, < 50 ms when the token is valid
* (single round-trip), no UI flicker (happens before render).
*/

const TOKEN_KEY = 'auth-session-token';
const ACTIVE_ORG_KEY = 'auth-active-organization-id';
/**
* Base name of the active-organization key. Since objectui#5664 the live key
* is per-user (`auth-active-organization-id:u:<userId>`) and the bare name is
* the retired pre-#5664 spelling, so the purge below matches BOTH by prefix.
*
* Spelled out rather than imported from `@object-ui/auth`: this module runs
* BEFORE React renders, and pulling the auth barrel in here would drag its
* component closure into the eager entry chunk.
*/
const ACTIVE_ORG_KEY_BASE = 'auth-active-organization-id';

/**
* Remove the stale active-organization id under every spelling — the bare
* pre-#5664 key and each `:u:<userId>` scope.
*
* Every scope goes, not just the current user's: a dead Bearer means this
* browser cannot say whose ids these are, and the value is a client-side cache
* of a SERVER-owned fact (`AuthProvider.refreshOrganizations` re-asks on the
* next boot), so deleting one that turns out to still be wanted costs a
* re-fetch and nothing else.
*
* `auth-session-user-id` is deliberately NOT removed. It is the pointer that
* lets the next sign-in notice it belongs to a DIFFERENT user and drop the
* previous user's state wholesale (objectui#5664); clearing it here would make
* that transition look like a first-ever sign-in and skip the purge.
*/
function purgeActiveOrgKeys(): void {
// Snapshot first — removing during a live index walk skips entries.
for (const key of Object.keys(localStorage)) {
if (key === ACTIVE_ORG_KEY_BASE || key.startsWith(`${ACTIVE_ORG_KEY_BASE}:u:`)) {
localStorage.removeItem(key);
}
}
}

export async function preflightAuth(authBaseUrl: string): Promise<void> {
if (typeof window === 'undefined') return;
Expand Down Expand Up @@ -69,7 +103,7 @@ export async function preflightAuth(authBaseUrl: string): Promise<void> {

// 401, 4xx, or 200-with-null — token is stale. Purge.
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_ORG_KEY);
purgeActiveOrgKeys();
} catch {
// Network error: assume token might still be good; don't punish the
// user by clearing it. Worst case AuthProvider will detect it next.
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ authorization input, and **not** what scopes rows.
| | |
| --- | --- |
| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) |
| Value read from | `ActiveOrganizationStorage` — `localStorage`, key `auth-active-organization-id` |
| Value read from | `ActiveOrganizationStorage` ([`src/ActiveOrganizationStorage.ts`](src/ActiveOrganizationStorage.ts)) — `localStorage`, key `auth-active-organization-id:u:$userId` (per-user since objectui#5664; the bare key is the retired pre-#5664 spelling) |
| Condition | that storage holds a non-empty value |
| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not |
| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work |
Expand Down
Loading
Loading