feat(auth): multi-session multi-profile support (client + SSR) - #14875
feat(auth): multi-session multi-profile support (client + SSR)#14875bobbor wants to merge 12 commits into
Conversation
Add userSignedIn, switchActiveUser, and userSignedOut events to AuthHubEventData, and add an optional user payload to signedOut and tokenRefresh. Supports the multi-session boundary event model.
Introduce an AuthUserList session roster (active user first) alongside LastAuthUser, and add setCurrentUser and listCurrentUsers. Sign-in/out now emit boundary Hub events (userSignedIn/switchActiveUser/ userSignedOut; signedIn/signedOut only at roster empty<->non-empty edges). Adds per-user token clearing, credential-cache busting on switch, and the createAuthSessionSwitcher primitive.
Add server variants of setCurrentUser and listCurrentUsers that accept a contextSpec and operate on the per-request (cookie-backed) token store. Reachability is via a minimal, non-destructive AuthSessionSwitcher (read + validated reorder only) surfaced by createUserPoolsTokenProvider and reached through a new additive AuthClass.getTokenProvider accessor. No destructive token operation crosses the server context boundary.
🦋 Changeset detectedLatest commit: add1897 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const resolvedUsers = await Promise.all( | ||
| roster.map(async rosterUsername => { | ||
| try { | ||
| const idTokenKey = `${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken`; |
There was a problem hiding this comment.
[major] This constructs the idToken storage key by hand (${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken) instead of delegating to authTokenStore.getStoredIdToken(rosterUsername), which already encapsulates exactly this key logic via getAuthKeys. The server path in apis/server/listCurrentUsers.ts correctly uses switcher.getStoredIdToken(). If the key schema ever changes, this client path will silently diverge.
Replace the manual key construction + raw getItem + decodeJWT block with:
const idToken = await authTokenStore.getStoredIdToken(rosterUsername);
if (!idToken) return undefined;
const { 'cognito:username': cognitoUsername, sub } = idToken.payload ?? {};This also removes the need for the inner try/catch and aligns the two paths perfectly.
There was a problem hiding this comment.
Good catch 👍 — this was exactly the drift the server path was built to avoid. Switched to authTokenStore.getStoredIdToken(), dropped the manual key + decodeJWT + inner try/catch. Fixed in 2c52b05.
| await clearCredentials(); | ||
|
|
||
| // Resolve the now-active user for the event payload. | ||
| const currentUser = await getCurrentUser(Amplify); |
There was a problem hiding this comment.
[minor] getCurrentUser(Amplify) goes through TokenOrchestrator.getTokens(), which can trigger a token refresh if the newly-active user's access token is expired. That's a surprising side-effect for what should be a cheap pointer move. dispatchSignOutBoundaryEvents handles the identical identity-resolution problem correctly by using getStoredIdToken() — this should do the same:
const idToken = await tokenStore.getStoredIdToken(username);
const userId = (idToken?.payload?.sub as string) ?? '';
Hub.dispatch('auth', { event: 'switchActiveUser', data: { username, userId } }, 'Auth', AMPLIFY_SYMBOL);This also removes the getCurrentUser import dependency from this file.
There was a problem hiding this comment.
Agreed, the refresh side-effect was surprising. Now resolves from stored tokens via getStoredIdToken() (mirroring dispatchSignOutBoundaryEvents), and skips the dispatch entirely if the identity can't be resolved — no more getCurrentUser import. Fixed in 2c52b05.
| this.getAuthUserListKey(), | ||
| list.join(','), | ||
| ); | ||
| await this.getKeyValueStorage().setItem(this.getLastAuthUserKey(), list[0]); |
There was a problem hiding this comment.
[minor] AuthUserList and LastAuthUser are written sequentially here. A crash between the two leaves them out of sync (AuthUserList = bob,alice but LastAuthUser = alice from the previous write). The delete path has the right ordering comment, but the write path has the same race in the other direction. Since getAuthUserList() already treats AuthUserList as authoritative when present, worth adding an explicit comment that LastAuthUser here is best-effort / compatibility-only and doesn't affect roster correctness if the write is lost — otherwise the two-write sequence looks like an unguarded bug.
There was a problem hiding this comment.
Added the clarifying comment — AuthUserList is authoritative (getAuthUserList prefers it), LastAuthUser is best-effort compat only, so a lost second write doesn't affect roster correctness. Fixed in 2c52b05.
| ); | ||
| if (legacyLastAuthUser && legacyLastAuthUser !== 'username') { | ||
| const migratedList = [legacyLastAuthUser]; | ||
| await this.persistAuthUserList(migratedList); |
There was a problem hiding this comment.
[minor] getAuthUserList is a read path, but on first invocation after upgrade it calls persistAuthUserList — a write. So listCurrentUsers (read-only by contract) silently mutates storage on its first call. In SSR with ephemeral or read-only storage this write will throw and break the read. Worth wrapping the migration write in a try/catch so that a storage failure during migration degrades gracefully rather than preventing the list from being returned.
There was a problem hiding this comment.
Nice edge case 👍 — wrapped the migration persist in try/catch; on read-only storage the read still returns the migrated list, persistence just retries next time. Test added. Fixed in 2c52b05.
| // drive a refresh) must not be invoked. | ||
| expect(loadTokensSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[nit] No test covers the signInDetails branch (the stored signInDetails key being present and populated on the returned AuthUser). Worth adding one case — the path is a distinct storage read that can fail independently of the idToken read.
There was a problem hiding this comment.
Added — one test with stored signInDetails populated on the returned AuthUser, one where the read fails and the user is still returned without it. Fixed in 2c52b05.
- listCurrentUsers (client): resolve idToken via getStoredIdToken instead of hand-building the storage key - setCurrentUser: resolve switchActiveUser payload from stored tokens (no refresh side-effect); skip dispatch when identity unresolvable - dispatchSignOutHubEvents: skip switchActiveUser instead of emitting an empty userId - TokenStore: make legacy-roster migration write fail-safe on read-only storage; document LastAuthUser as best-effort compat - server listCurrentUsers: document missing signInDetails in JSDoc - tests for the signInDetails branch and updated paths
|
@soberm thanks for the thorough review! All 7 comments addressed in 2c52b05 — the major (hand-built storage key in client |
- getAuthUserList now reconciles LastAuthUser drift from external writers (amazon-cognito-identity-js, older Amplify versions): a non-sentinel LastAuthUser differing from the roster head is promoted, entries without a stored access token are pruned, and the result is re-persisted best-effort (also repairs our own partial writes) - persistAuthUserList writes LastAuthUser before AuthUserList so a partial failure carries the newer intent for reconciliation - clearTokensForUser preserves device-tracking keys (deviceKey, deviceGroupKey, randomPasswordKey) for remembered-device parity with legacy clearTokens; only forgetDevice/deleteUser clear device metadata
Per the updated HLD, LastAuthUser becomes a pure active pointer that
may be EMPTY while AuthUserList still holds parked sessions:
- sign-out (native, OAuth, refresh-failure) clears only the signing-out
user and the active pointer; it never promotes a parked session and
always emits signedOut. Reactivating a parked session is the app's
job via setCurrentUser, which then emits signedIn
- getLastAuthUser reads the pointer directly (sentinel when empty) and
no longer derives from AuthUserList[0]; parked-only state reads as
signed-out
- new TokenStore.clearActiveUser(); removeSession returns { isEmpty }
and leaves the pointer untouched; persistAuthUserList gains a
setPointerTo option as the single pointer-writer
- sign-in/setCurrentUser boundary events are pointer-based: signedIn
when no user was active, switchActiveUser when switching between
active users, neither on same-user re-auth
- drift reconciliation treats empty-pointer-with-parked-roster as
legitimate and clears (not repoints) a pruned stale pointer
|
Heads-up: design update landed in 2cf38e5 (per the revised HLD) — sign-out no longer auto-promotes a parked session. |
…r e2e Remove the assertUserNotAuthenticated gate from native signIn per the HLD (a second signIn adds the user to the roster head as active). signInWithRedirect keeps its prompt gate (Hosted UI SSO semantics). Register integ_react_auth_multi_session e2e test.
|
@osama-rizk e2e tests added 👍
One infra prerequisite before the run can go green: user B ( |
|
Prerequisite resolved ✅ — |
On pull_request events github.ref_name is the synthetic '<pr>/merge' ref, so the staging-branch matching never found the PR's samples branch and silently fell back to main. Use github.head_ref when present (PR runs) and keep ref_name for push-triggered runs.
…nter storeTokens resolved its storage keys via no-arg getAuthKeys(), which reads the active pointer. cacheCognitoTokens runs before the sign-in flow sets the pointer (addActiveSession), so a fresh sign-in wrote tokens under the stale/sentinel namespace and the newly-active user's namespace stayed empty -> getCurrentUser threw after every sign-in. Resolve keys from tokens.username (HLD \u00a74.3), restore the real cache-then-activate order in the masking unit test, and add regression tests for the fresh and second-user flows.
|
Root cause of the red e2e wall found and fixed in 6315d2d — and it was a real product bug in this branch, not flake: |
Two follow-on regressions from decoupling storeTokens from the active pointer: - SSR path-cookie migration: the cookie adapter migrates (delete old path, re-set at path:'/') only keys WRITTEN during refresh; with storeTokens no longer writing LastAuthUser, its path-scoped cookie was never migrated. refreshTokens now calls the new pointer-only, roster-safe reassertActiveUserPointer(username) after persisting. - OAuth metadata namespace: completeOAuthFlow called setOAuthMetadata before the sign-in flow set the active pointer, so metadata landed under the sentinel namespace and getOAuthMetadata found nothing on a second subdomain -> hosted-UI logout redirect skipped. setOAuthMetadata now accepts the owning username and completeFlow passes it. Tests cover pointer re-assert semantics (parked/sentinel no-ops), refresh integration, and username-keyed oauth metadata.
…er is set completeFlow resolved inflight promises (releasing blocked getCurrentUser/fetchAuthSession callers) before dispatchSignedInHubEvent set the LastAuthUser pointer via addActiveSession. A released caller then resolved tokens against the sentinel namespace and threw UserUnAuthenticatedException. Only the code-grant flow was affected: its /oauth2/token fetch creates a real inflight window, while the implicit flow completes synchronously. Release now happens in a finally after the sign-in dispatch (no deadlock: clearOAuthData clears the inflight gate earlier), with a regression test asserting the order.
Description
Adds multi-session / multi-profile support to Cognito auth: multiple users can be signed in to the same user pool simultaneously, with one active session at a time and the others parked. Works both client-side and in SSR (HttpOnly cookies).
New public APIs
setCurrentUser(username)— switch the active session to an already-signed-in user (throws if not signed in). Client + server.listCurrentUsers(): Promise<AuthUser[]>— list all signed-in users, active first. Client + server.Server variants live under
aws-amplify/auth/serverand accept acontextSpec, operating on the per-request cookie-backed token store.Storage model
AuthUserListkey holds a comma-separated, ordered roster (active first).LastAuthUserremains the single-username active pointer for cross-SDK compatibility and may be EMPTY while parked sessions remain: sign-out never auto-promotes a parked session — the app reactivates one explicitly viasetCurrentUser. Per-user token namespaces are unchanged.Hub events (boundary model)
userSignedIn,switchActiveUser,userSignedOut(per-session roster membership / active-pointer moves).signedInfires when the active pointer goes none→some (first sign-in, orsetCurrentUserreactivating a parked session);signedOutfires on EVERY active-user sign-out (even with parked sessions remaining). Payloads unchanged ({ username, userId }), with an optional user payload added tosignedOut/tokenRefresh. Backward compatible for existing single-session apps.Server-side safety
Server exposure is deliberately minimal and non-destructive: the per-request token provider surfaces only a narrow
AuthSessionSwitcher(read + validated reorder). Destructive token-store operations (storeTokens,clearTokens,clearTokensForUser,removeSession) never cross the server context boundary. Reachability is via a new additiveAuthClass.getTokenProvider()accessor; core's genericTokenProviderinterface is unchanged.Commits
feat(core): add multi-session auth Hub eventsfeat(auth): add client-side multi-session supportfeat(auth): expose multi-session APIs for server-side renderingTesting
yarn buildclean across@aws-amplify/auth,aws-amplify,@aws-amplify/adapter-nextjs.@aws-amplify/auth: full unit suite green (1193+ tests), incl. new tests for the roster, boundary events,setCurrentUser/listCurrentUsers(client + server), and the session switcher.aws-amplify: 51/51 incl. the API-surfaceexportsguard (updated for the intended new symbols only).yarn lintclean.Amplify.getConfig), per repo convention.Notes
adapter-nextjsrequired no change — the server APIs are callable throughrunWithAmplifyServerContextlikegetCurrentUser.Checklist