feat: share one session across origins with a shared session storage backend - #144
feat: share one session across origins with a shared session storage backend#144sea-snake wants to merge 18 commits into
Conversation
An AuthClient keeps its session in the storage of the origin it runs on, so an app served from several origins asks the user to sign in on each one, and each gets a different principal. Adds a storage backend that keeps the session on another origin and proxies operations to it over postMessage, so the origins authorized by a derivation origin share one sign-in. Nothing is stored on the consuming origins, and the session never travels in an HTTP header the way a cookie would. Which origins may read the session is the derivation origin's .well-known/ii-alternative-origins record — the same record Internet Identity reads when deriving a principal for an alternative origin. Every origin listed there can already obtain a delegation for that principal by signing in with derivationOrigin set, so serving a stored session to exactly that set grants no further authority, and there is one list to maintain instead of two that can drift. - `SharedSessionStorage` (client) and `serveSharedSession` (hub), the latter on a new `@icp-sdk/auth/shared-session` entry point so app bundles don't carry the hub and vice versa. - `sharedSessionHub` on the constructor. It requires `derivationOrigin` and excludes `storage` in the types: sharing a store without a shared derivation origin would give each origin a different principal from the same key. When both arrive from JavaScript the hub wins, with a warning. - A versioned wire protocol, checked on both sides. Hub and clients are separate deploys and can run different versions of this package. Messages without an id are reserved for hub-initiated pushes, so a client meeting a future invalidation ignores it rather than failing to parse it. - The hub reads the record through `<canisterId>.icp0.io` when given a canister id, where a boundary node verifies certification, and same-origin when it owns the derivation origin. Discovery failures fail closed without being cached. Two fixes this exposed, both of which also affect the existing IndexedDB path: - `isAuthenticated()` reads a per-origin localStorage expiration cache written only on sign-in, so an origin restoring a shared session reported no session at all. It is now written on restore and cleared when the store turns out to be empty, which also settles the case of storage cleared behind the client's back. - `restoreKey` awaits `storage.get` outside its try/catch, so a read failure rejected the hydration promise the constructor starts without awaiting: an unhandled rejection, and a getIdentity() that rejects for the page's lifetime. Unreachable storage is routine for a remote store, so hydration now absorbs it and stays anonymous, leaving the cached expiration alone because a hub that failed to answer has not said the session is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a cross-origin “shared session” mechanism for AuthClient by moving session persistence to a dedicated hub origin and proxying storage operations via a versioned postMessage protocol, enabling one sign-in across authorized alternative origins (as defined by /.well-known/ii-alternative-origins). It also fixes two previously exposed session/restore edge cases in AuthClient around isAuthenticated() caching and hydration error handling.
Changes:
- Add shared-session hub + client storage proxy (
serveSharedSession,SharedSessionStorage) with protocol versioning and allow-list discovery. - Extend
AuthClientcreate options withsharedSessionHub(mutually exclusive withstorage) and harden hydration / expiration caching behavior. - Add comprehensive tests and documentation, plus a new
@icp-sdk/auth/shared-sessionexport entry point.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/shared-session/storage.test.ts | Tests the client-side SharedSessionStorage behavior (handshake, ops, timeouts, retries). |
| tests/shared-session/serve.test.ts | Tests hub-side serveSharedSession authorization, protocol checks, and discovery behavior. |
| tests/shared-session/options.test-d.ts | Type-level assertions for the new AuthClient option constraints. |
| tests/client/auth-client.test.ts | Adds AuthClient regression coverage for expiration caching + unreachable storage hydration. |
| src/shared-session/storage.ts | Implements the client-side storage proxy over postMessage using a hidden iframe. |
| src/shared-session/serve.ts | Implements the hub listener, allow-list discovery, and storage application logic. |
| src/shared-session/protocol.ts | Defines the wire protocol, versioning, and origin parsing utilities. |
| src/shared-session/index.ts | New shared-session entry point exports for hub-side usage. |
| src/client/index.ts | Re-exports shared-session storage client-side types/implementation. |
| src/client/auth-client.ts | Adds sharedSessionHub options union, storage selection, hydration hardening, and expiration cache fixes. |
| README.md | Documents shared-session usage at a high level. |
| package.json | Adds the ./shared-session export entry. |
| docs/typedoc.json | Adds shared-session Typedoc entry point. |
| docs/src/content/docs/shared-session.md | New guide explaining authorization, deployment, and behavior. |
| docs/src/content/docs/_sidebar.json | Adds shared-session docs to the sidebar/navigation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const onMessage = (event: MessageEvent) => { | ||
| if (event.origin !== this.#hubOrigin || !isChannelMessage(event.data)) return; | ||
| const data = event.data as Partial<SharedSessionResponse>; | ||
| if (data.id !== id) return; | ||
| settle(() => { | ||
| if (data.error !== undefined) { | ||
| reject(new Error(`Shared session hub refused ${op}: ${data.error}`)); | ||
| return; | ||
| } | ||
| resolve(data.result ?? null); | ||
| }); | ||
| }; |
| function parseAbsoluteUrl(value: string | URL, label: string): URL { | ||
| try { | ||
| return new URL(value.toString()); | ||
| } catch { | ||
| throw new Error(`Shared session ${label} must be an absolute URL, got "${value.toString()}".`); | ||
| } | ||
| } |
… store isAuthenticated() answers synchronously, so it cannot read the shared session store: that one is asynchronous and belongs to another origin. It read a per-origin localStorage cache, which an origin sharing a session has no way to populate before its first restore. Introduces AuthClientSyncStorage with two implementations, selected by mode: SyncLocalStorage as before, and SyncCookieStorage when sharedSessionHub is set. A cookie is the only store that is both synchronous and visible across origins, which is exactly the shape of this problem. Override with `syncStorage`. The cookie holds one non-secret value, and Max-Age is set from the delegation's own expiration, so it cannot outlive what it describes and the expired-but-cached state stops existing rather than being handled. Signing out on any origin deletes it for all of them, which also closes the stale-flag window the previous commit documented. It is a hint, not authority: every subdomain can write a domain cookie and nothing records which one did, so a forged value can make isAuthenticated() briefly wrong. The identity is still read from the shared store and verified, so the reachable outcome is a wrong render, not an authentication. getExpirationFlag now tolerates an unparseable value for the same reason — it must not throw inside a synchronous getter. A cookie can only be scoped to the current host or a domain above it, so the cookie store is chosen only when the derivation origin is this origin or a parent of it. With a sibling derivation origin the write would be silently dropped and every read empty, which is worse than the localStorage behaviour it replaces, so that case keeps localStorage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TypeScript lets a two-parameter method satisfy the interface's three-parameter signature, so `tsc` accepted it, but a caller holding the concrete class type could not pass `expiresAt` — which vitest's typecheck caught as an unhandled source error while every test still passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three cases constructed a hub-backed client and returned immediately, leaving the handshake timeout to fire after the environment was torn down — where the globals its cleanup touches no longer exist, so it raised an unhandled ReferenceError while every test still reported passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hub has to be same-site with the origins it serves, or its storage is partitioned and there is nothing to share. The derivation origin carries no such constraint and may be an unrelated domain entirely. Scoping to the derivation origin therefore broke the case where the two differ: a hub at example.com serving a.example.com and b.example.com, with a derivation origin elsewhere, could not write the cookie at all and silently fell back to localStorage even though a cookie would have worked. It also stops sending the hint to a domain that may have nothing else to do with the deployment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ctly A shared session is a storage backend, so the option was sugar over `storage`. Removing it takes the union type with it: AuthClientCreateOptions is a plain interface again, so `interface Foo extends AuthClientCreateOptions` and declaration merging keep working and this stops being a breaking type change. The `storage?: never` branch and the runtime precedence warning go too. The constraint the union enforced survives where it belongs: SharedSessionStorage requires `derivationOrigin` in its own options, so a shared store without a shared derivation origin is still a type error. What is lost is the guarantee that it matches the one AuthClient signs in with — they are separate options now, and the docs say to keep them in one constant. The expiration cookie becomes explicit too, via `syncStorage`, which drops the derived-domain guesswork: the app names the domain its origins have in common, so a sibling hub works as well as a parent one. SyncCookieStorage gains a `namespace`, since two shared sessions under one domain would otherwise write the same cookie and describe each other's sessions. SharedSessionStorage exposes `hubOrigin` to pass as that namespace, and a namespace that parses as a URL is reduced to its origin so the hub's URL and its origin cannot name two cookies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hub: { url, timeout }` was shaped for an AuthClient option that no longer
exists. As the storage's own options they are just `url` and `timeout`, so
SharedSessionHubOptions goes away.
SyncCookieStorage takes `derivationOrigin` in place of a free-form `namespace`.
It is what the cookie is about — the expiration of a delegation for that
origin's principal — it is the same constant already passed to the session's
storage and to AuthClient, and it removes both the namespace concept and the
`hubOrigin` accessor that existed only to feed it. It is required, so two
deployments under one domain cannot collide by omission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SharedSessionStorage declared it only so the hub could refuse a client that disagreed with its own configuration, and SyncCookieStorage only to keep two shared sessions under one domain apart. Neither is worth a third and fourth place to keep the same value in sync, so both options go, along with the wire field and the hub's comparison. `derivationOrigin` now appears twice: on AuthClient, which decides the principal, and on serveSharedSession, which decides whose record authorizes readers. A client signing in with one the hub is not configured for no longer fails loudly, and the cookie assumes one shared session per domain — both documented. Also rewrites the guide for implementers: what to set and which values, in three steps, without the reasoning that belongs in review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or it The hub is not necessarily served from the derivation origin, so it has to read a record belonging to another origin — and reading that over the origin's own domain lets anyone able to tamper with its responses widen the set of origins allowed to read the session. Reads it through the canister-id gateway URL instead, where a boundary node verifies certification, and resolves the canister id the way Internet Identity does rather than taking it as an option: the leftmost label on a well-known IC domain, otherwise the x-ic-canister-id header a boundary node sets on a HEAD of the origin. A local replica is addressed by query parameter. Where the hub does own the derivation origin the record is read same-origin, needing no resolution at all. That header is not certified, so a party able to tamper with an origin's responses can name a canister of their own and the certified read that follows verifies the wrong record. Internet Identity resolves the same way and inherits the same limit; it is documented on the resolver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opens with the case it solves — several apps under one custom domain needing the same principal and the same sign-in state — and that a derivationOrigin alone fixes the principal without sharing the session. Drops the commentary that followed: what isAuthenticated() reads, how to treat it, what a cookie domain must not be, what stays unchanged. Names the record's home as the derivation origin rather than the page's origin, which are not necessarily the same. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… guide Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The .adoc path in the internet-identity repository no longer resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| ``` | ||
|
|
||
| ```typescript | ||
| import { serveSharedSession } from '@icp-sdk/auth/shared-session'; |
There was a problem hiding this comment.
Can this be vanilla JS?
Can we inline it into the HTML to avoid having two snippets?
It is exported there, alongside AuthClient, not from the shared-session entry point the examples named. That one is the hub half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a shared session the expiration cache is shared too, so an origin that cannot see the session was deleting the hint every other origin reads. Found while testing a real deployment: one app wiped the cookie the other had written, which looked like the cookie being unshared and cost several rounds of diagnosis. The cache carries the delegation's own expiry, so it lapses without help. Signing out and an expired or corrupt chain still clear it, both through deleteStorage, so the case this branch covered is narrowed to a store emptied behind the client's back, where isAuthenticated() now stays true until the delegation would have expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f45ddf4 to
8dd3e1e
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8dd3e1e to
5871030
Compare
Problem
A session belongs to the origin it was created on, so an application served from
docs.example.comandchat.example.comasks the user to sign in twice and gives each origin a different principal.A cross-subdomain cookie would share it, but it rides along on every request to the domain, is readable by any script on it, and cannot hold the default non-extractable
CryptoKeyPair.Approach
Sharing a session is a storage concern, so this adds storage backends rather than new sign-in options:
SharedSessionStoragekeeps the session on the hub page's origin and proxiesget/set/removeto it overpostMessage. Nothing is stored on the consuming origins and no credential enters an HTTP header.Which origins may read it is the derivation origin's
.well-known/ii-alternative-originsrecord — the same record Internet Identity reads when deriving a principal for an alternative origin. Reusing it is deliberate: every origin listed there can already obtain a delegation for that principal by signing in withderivationOriginset, so serving a stored session to exactly that set grants no further authority, and there is one list to maintain instead of two that can drift.SyncCookieStoragebacksisAuthenticated(), which is synchronous and so cannot read the shared session. It holds one non-secret value — the delegation's expiration, withMax-Ageto match — and is a hint, not authority: any subdomain can write it, but the identity is still read from the shared session and verified, so a forged value causes a wrong render, not an authentication.Notes for review
@icp-sdk/auth/shared-session, so app bundles don't carry the hub and hub pages don't carryAuthClient.AuthClientCreateOptionsstays a plain interface; the only addition issyncStorage. An earlier revision added asharedSessionHuboption that required making it a union — dropped in favour of settingstoragedirectly.idare reserved for hub-initiated pushes, so a client meeting a future invalidation ignores it rather than failing to parse it. Live invalidation is not implemented; the docs already say another origin may end the session, so adding it later isn't breaking.<canisterId>.icp0.io, where a boundary node verifies certification — the same precautionvalidateDerivationOrigintakes in the II frontend. The canister id is resolved rather than configured, mirroring II'sresolveCanisterId: leftmost label on a well-known IC domain, otherwise thex-ic-canister-idheader from aHEADof the origin, with a local replica addressed by query parameter. Same-origin when the hub happens to own the derivation origin. That header is uncertified, so a party able to tamper with an origin's responses can name a canister of their own — II inherits the same limit, and it is documented on the resolver. Discovery failures deny every client and are not cached.https-origin parsing for record entries (path, port, trailing slash, or credentials rejected),redirect: 'error',credentials: 'omit', II's 10-entry cap, andevent.sourceverification on the handshake so another frame on the hub's origin cannot answer for it.derivationOriginmatches the hub's, and the cookie assumes one shared session per domain. Earlier revisions enforced both; the options they needed weren't worth keeping a third and fourth copy of the same value in sync.biome-ignores fornoDocumentCookie— the rule suggests the Cookie Store API, which is async, the opposite of what that store is for.Two fixes this exposed
Both also affect the existing IndexedDB path:
isAuthenticated()reported no session on an origin sharing one. It reads an expiration cache that onlypersistChainwrote, so an origin that restored a session but never signed in had nothing cached. Now written on restore and cleared when the store proves empty — which also settles the pre-existing case of storage cleared behind the client's back, where it returnedtruewhilegetIdentity()was anonymous.A storage read failure produced an unhandled rejection.
restoreKeyawaitsstorage.getoutside its try/catch, so a failure rejected the hydration promise the constructor starts without awaiting, leavinggetIdentity()rejecting for the page's lifetime. Unreachable storage is routine for a store on another origin, so hydration now absorbs it, logs, and stays anonymous — without clearing the cached expiration, since a hub that failed to answer has not said the session is gone.Testing
pnpm test— 147 passing, no type errors;pnpm build,publint --strictandbiome checkclean.Hub tests drive the real
serveSharedSessionlistener with synthesizedMessageEvents; client tests drive the real class against a stand-in hub window, covering the handshake, version mismatch, denial, timeouts naming the resolved URL, and frame cleanup on a failed connection. Cookie tests assert the attributes (Domain,SameSite,Secure,Max-Age), whichdocument.cookiecannot read back, by capturing writes.🤖 Generated with Claude Code