Skip to content

feat: share one session across origins with a shared session storage backend - #144

Closed
sea-snake wants to merge 18 commits into
mainfrom
feat/shared-session-storage
Closed

feat: share one session across origins with a shared session storage backend#144
sea-snake wants to merge 18 commits into
mainfrom
feat/shared-session-storage

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

A session belongs to the origin it was created on, so an application served from docs.example.com and chat.example.com asks 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:

// a page on any origin you control
serveSharedSession({ derivationOrigin: 'https://auth.example.com' });

// every consuming origin
new AuthClient({
  storage: new SharedSessionStorage({ url: 'https://auth.example.com/shared-session.html' }),
  syncStorage: new SyncCookieStorage({ domain: 'example.com' }),
  derivationOrigin: 'https://auth.example.com',
});

SharedSessionStorage keeps the session on the hub page's origin and proxies get/set/remove to it over postMessage. 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-origins record — 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 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.

SyncCookieStorage backs isAuthenticated(), which is synchronous and so cannot read the shared session. It holds one non-secret value — the delegation's expiration, with Max-Age to 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

  • New entry point @icp-sdk/auth/shared-session, so app bundles don't carry the hub and hub pages don't carry AuthClient.
  • AuthClientCreateOptions stays a plain interface; the only addition is syncStorage. An earlier revision added a sharedSessionHub option that required making it a union — dropped in favour of setting storage directly.
  • Versioned wire protocol, checked on both sides, since hub and clients are separate deploys. 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. Live invalidation is not implemented; the docs already say another origin may end the session, so adding it later isn't breaking.
  • Allow-list reads go through <canisterId>.icp0.io, where a boundary node verifies certification — the same precaution validateDerivationOrigin takes in the II frontend. The canister id is resolved rather than configured, mirroring II's resolveCanisterId: leftmost label on a well-known IC domain, otherwise the x-ic-canister-id header from a HEAD of 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.
  • Input guards: bare-https-origin parsing for record entries (path, port, trailing slash, or credentials rejected), redirect: 'error', credentials: 'omit', II's 10-entry cap, and event.source verification on the handshake so another frame on the hub's origin cannot answer for it.
  • Two deliberate gaps, both documented: nothing checks that a client's derivationOrigin matches 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.
  • Two biome-ignores for noDocumentCookie — the rule suggests the Cookie Store API, which is async, the opposite of what that store is for.
  • Cross-registrable-domain sharing is out of scope: that storage is partitioned in all engines, so an off-domain record entry reads an empty store rather than leaking.

Two fixes this exposed

Both also affect the existing IndexedDB path:

  1. isAuthenticated() reported no session on an origin sharing one. It reads an expiration cache that only persistChain wrote, 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 returned true while getIdentity() was anonymous.

  2. A storage read failure produced an unhandled rejection. restoreKey awaits storage.get outside its try/catch, so a failure rejected the hydration promise the constructor starts without awaiting, leaving getIdentity() 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 --strict and biome check clean.

Hub tests drive the real serveSharedSession listener with synthesized MessageEvents; 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), which document.cookie cannot read back, by capturing writes.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 7, 2026 13:09
@sea-snake
sea-snake requested a review from a team as a code owner August 7, 2026 13:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 AuthClient create options with sharedSessionHub (mutually exclusive with storage) and harden hydration / expiration caching behavior.
  • Add comprehensive tests and documentation, plus a new @icp-sdk/auth/shared-session export 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.

Comment on lines +114 to +125
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);
});
};
Comment on lines +231 to +237
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()}".`);
}
}
sea-snake and others added 5 commits August 7, 2026 15:36
… 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>
@sea-snake sea-snake changed the title feat: share one session across origins via a derivation-origin hub feat: share one session across origins with a shared session storage backend Aug 7, 2026
sea-snake and others added 9 commits August 7, 2026 16:08
`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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can this be vanilla JS?

Can we inline it into the HTML to avoid having two snippets?

sea-snake and others added 2 commits August 7, 2026 18:03
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>
@sea-snake
sea-snake force-pushed the feat/shared-session-storage branch from f45ddf4 to 8dd3e1e Compare August 7, 2026 20:49
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the feat/shared-session-storage branch from 8dd3e1e to 5871030 Compare August 7, 2026 20:50
@sea-snake

Copy link
Copy Markdown
Contributor Author

Abandoned in favor of the configurable-storage approach — shared sessions are now handled by SyncCookieStorage (#149) + the guide in #150.

@sea-snake sea-snake closed this Aug 14, 2026
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.

3 participants