Skip to content

feat(auth): multi-session multi-profile support (client + SSR) - #14875

Open
bobbor wants to merge 12 commits into
mainfrom
auth/feat/multi-session-support
Open

feat(auth): multi-session multi-profile support (client + SSR)#14875
bobbor wants to merge 12 commits into
mainfrom
auth/feat/multi-session-support

Conversation

@bobbor

@bobbor bobbor commented Jul 13, 2026

Copy link
Copy Markdown
Member

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/server and accept a contextSpec, operating on the per-request cookie-backed token store.

Storage model

  • New AuthUserList key holds a comma-separated, ordered roster (active first). LastAuthUser remains 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 via setCurrentUser. Per-user token namespaces are unchanged.

Hub events (boundary model)

  • New: userSignedIn, switchActiveUser, userSignedOut (per-session roster membership / active-pointer moves).
  • signedIn fires when the active pointer goes none→some (first sign-in, or setCurrentUser reactivating a parked session); signedOut fires on EVERY active-user sign-out (even with parked sessions remaining). Payloads unchanged ({ username, userId }), with an optional user payload added to signedOut/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 additive AuthClass.getTokenProvider() accessor; core's generic TokenProvider interface is unchanged.

Commits

  1. feat(core): add multi-session auth Hub events
  2. feat(auth): add client-side multi-session support
  3. feat(auth): expose multi-session APIs for server-side rendering

Testing

  • yarn build clean 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-surface exports guard (updated for the intended new symbols only).
  • yarn lint clean.
  • Underlying modules mocked (not Amplify.getConfig), per repo convention.

Notes

  • adapter-nextjs required no change — the server APIs are callable through runWithAmplifyServerContext like getCurrentUser.
  • Concurrent active sessions are out of scope (one active user at a time).

Checklist

  • Tests added/updated
  • Changeset added
  • Build + lint pass locally

bobbor added 4 commits July 13, 2026 12:46
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.
@bobbor
bobbor requested review from a team, avi-karthik, pranavosu and sarayev as code owners July 13, 2026 13:03
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: add1897

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@aws-amplify/auth Minor
@aws-amplify/core Minor
aws-amplify Minor
@aws-amplify/pubsub Patch
@aws-amplify/api-graphql Patch
@aws-amplify/api Patch
@aws-amplify/datastore Patch

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

@osama-rizk osama-rizk added the run-tests run the pr-label workflow label Jul 15, 2026
const resolvedUsers = await Promise.all(
roster.map(async rosterUsername => {
try {
const idTokenKey = `${AUTH_KEY_PREFIX}.${userPoolClientId}.${rosterUsername}.idToken`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/auth/src/providers/cognito/apis/server/listCurrentUsers.ts
Comment thread packages/auth/src/providers/cognito/utils/dispatchSignOutHubEvents.ts Outdated
this.getAuthUserListKey(),
list.join(','),
);
await this.getKeyValueStorage().setItem(this.getLastAuthUserKey(), list[0]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@bobbor

bobbor commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

@soberm thanks for the thorough review! All 7 comments addressed in 2c52b05 — the major (hand-built storage key in client listCurrentUsers) now goes through getStoredIdToken() like the server path, plus the refresh side-effect in setCurrentUser, the empty-userId dispatch, the migration write-on-read, and the doc/test items. Full auth suite green locally (107 suites / 1199 tests). Ready for another look 👀

@osama-rizk osama-rizk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please add e2e tests ?

Also, I ran the existing e2e tests — some failed, though a few look flaky. Can you please check them?

bobbor added 2 commits August 26, 2026 14:04
- 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
@bobbor

bobbor commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Heads-up: design update landed in 2cf38e5 (per the revised HLD) — sign-out no longer auto-promotes a parked session. LastAuthUser is now a pure active pointer that can be empty while AuthUserList keeps parked sessions; after the active user signs out the app is signed-out (signedOut always fires) until it explicitly reactivates a parked session via setCurrentUser (which then fires signedIn). Also in the last commits: drift reconciliation for external LastAuthUser writers, device-key preservation on sign-out (remembered-device/MFA parity), and signInWithRedirect({ options: { prompt } }) as the add-another-user gate. PR description updated. Full auth suite green (107 suites / 1216 tests) + full monorepo build clean.

…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.
@bobbor
bobbor requested a review from a team as a code owner August 26, 2026 15:10
@bobbor

bobbor commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

@osama-rizk e2e tests added 👍

  • New sample samples/react/auth/multi-session + Cypress spec cypress/integration/auth/multi-session.spec.js on the matching auth/feat/multi-session-support branch of amplify-js-samples-staging (the missing matching branch was also why the whole existing e2e wall was red — those failures were infra, not product).
  • The spec walks the full no-promotion model: sign in A → add B via second signInlistCurrentUsers roster → setCurrentUser switch → sign out active (app signed-out, B stays parked, signedOut fires) → reactivate B (signedIn fires) → final sign-out empties the roster. It also asserts the Hub event log order.
  • Registered as integ_react_auth_multi_session in .github/integ-config/integ-all.yml (4cd7734).
  • Enabling this surfaced a real product gap: native signIn still threw UserAlreadyAuthenticatedException for a second user — removed per the HLD in the same commit (OAuth keeps its prompt gate).

One infra prerequisite before the run can go green: user B (test02) needs provisioning in the shared test pool us-east-1_6ADT7mHZF alongside test01.

@bobbor

bobbor commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Prerequisite resolved ✅ — test02 existed in pool us-east-1_6ADT7mHZF (account owning the shared cognitofdefb8ad e2e backend) but with an unknown password; reset to the conventional The#test2 as a permanent password (no other spec authenticates test02 against this pool — the datastore specs use different pools). integ_react_auth_multi_session is now fully runnable.

@bobbor bobbor added run-tests run the pr-label workflow and removed run-tests run the pr-label workflow labels Aug 27, 2026
bobbor added 2 commits August 27, 2026 14:24
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.
@bobbor

bobbor commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Root cause of the red e2e wall found and fixed in 6315d2d — and it was a real product bug in this branch, not flake: storeTokens resolved its storage keys from the active pointer (no-arg getAuthKeys()), but token caching runs before sign-in sets that pointer. So every fresh sign-in wrote tokens into the wrong namespace and the newly-active user's namespace stayed empty → getCurrentUser threw after every sign-in. That's why all auth e2e suites (incl. long-green javascript_authentication) failed on this branch. Tokens are now namespaced by tokens.username (per HLD §4.3), the unit test that masked the ordering is restored to the real cache-then-activate order, and regression tests cover both the fresh and second-user flows. e2e re-running on 6315d2d.

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.
@bobbor bobbor added run-tests run the pr-label workflow and removed run-tests run the pr-label workflow labels Sep 1, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-tests run the pr-label workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants