Skip to content
Open
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
75 changes: 73 additions & 2 deletions src/client/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,23 @@ export type SessionStatus =
| { status: 'expired'; principal: Principal; expiration: bigint }
| { status: 'signed-out' };

/**
* Thrown when a sign-in exists within the state store's reach but this origin
* holds no credential for it.
*
* A sibling subdomain reads the shared record on its first load and is in exactly
* this position: someone is signed in, and it has nothing to act with until it
* acquires its own. Catching this is where a silent re-issue belongs.
*/
export class SessionNotHeldError extends Error {
constructor(
message = 'A sign-in exists for this domain, but this origin holds no credential for it',
) {
super(message);
this.name = 'SessionNotHeldError';
}
}

/**
* Manages authentication and identity for Internet Computer web apps.
*
Expand Down Expand Up @@ -297,6 +314,8 @@ export class AuthClient {
// next event finds a fresher answer than the one already in flight anyway.
#refreshingInForeground = false;
#disposed = false;
// Set while a restore is running, so its own writes are not news.
#restoring = false;
#stateStorage: StateStorage;
#signer: Signer;
// Set only in redirect mode, so the redirect-specific paths (nonce/key
Expand Down Expand Up @@ -356,6 +375,16 @@ export class AuthClient {
// answering for a sign-in the record no longer names is wrong rather than
// stale.
this.#unwatchState = this.#stateStorage.subscribe(this.#slots.state, () => {
// Only a change this client did not cause. A ceremony writes this record
// itself and installs the identity that goes with it, and a restore writes
// it too when what it found turns out not to be usable — reacting to
// either would read a sign-in back out of the store while the thing that
// wrote it was still working. The same reason the foreground refresh
// stands down for a ceremony.
//
// What this gives up is a peer's change arriving during our own restore,
// which the next change reports.
if (this.#interactions > 0 || this.#restoring || this.#disposed) return;
this.#restoreAgain();
});
if (options.openIdProvider) {
Expand Down Expand Up @@ -399,6 +428,25 @@ export class AuthClient {
*/
async getIdentity(): Promise<Identity> {
await this.#init();

// A record exists and this client holds nothing to act with. Handing back an
// anonymous identity here is the dangerous answer: calls would go out
// unauthenticated while `isAuthenticated()` and the record both say someone
// is signed in. Failing by name is what lets a caller acquire one.
//
// Any record, not only one this origin does not hold. A sibling subdomain
// arriving without a credential is the case this was written for, and it is
// not the only way to get here: a store that cannot report a change — or
// reports it wrongly — leaves this client on an answer the record has moved
// past, and `held` is `true` for every record a same-origin store keeps, so
// asking about it would have let exactly that through.
//
// A disposed client is exempt: it holds nothing because it was told to stop,
// which is not the same as being unable to act on a sign-in that exists.
const state = this.#stateStorage.get(this.#slots.state);
if (state !== null && !this.#disposed && this.#identity instanceof AnonymousIdentity) {
throw new SessionNotHeldError();
}
return this.#identity;
}

Expand Down Expand Up @@ -1175,7 +1223,7 @@ export class AuthClient {
#restoreAgain(): void {
const promise = (this.#initPromise ?? Promise.resolve())
.catch(() => undefined)
.then(() => this.#hydrate())
.then(() => this.#restore())
.catch((error: unknown) => {
if (this.#initPromise === promise) {
this.#initPromise = null;
Expand All @@ -1196,7 +1244,7 @@ export class AuthClient {
// life of the page rejecting with an error nothing can retry past.
#init(): Promise<void> {
if (!this.#initPromise) {
const promise = this.#hydrate().catch((error: unknown) => {
const promise = this.#restore().catch((error: unknown) => {
if (this.#initPromise === promise) {
this.#initPromise = null;
}
Expand All @@ -1207,6 +1255,17 @@ export class AuthClient {
return this.#initPromise;
}

// Marks a restore as running, so what it writes does not come back as a
// record that changed under this client.
async #restore(): Promise<void> {
this.#restoring = true;
try {
await this.#hydrate();
} finally {
this.#restoring = false;
}
}

// Attempts to restore a previous session (key + delegation chain) from
// storage. If found and still valid, sets #identity and #chain so the
// client is ready to use without a new signIn().
Expand Down Expand Up @@ -1241,6 +1300,18 @@ export class AuthClient {
identity.dispose();
return;
}

// The state decides who is signed in here, and a sibling subdomain can have
// changed it while this origin was away. Credentials rooted at an account the
// state no longer names belong to a sign-in that has ended, so they go rather
// than being restored — the app credential with them, since `#openSession`
// may have minted one for an account the state no longer names.
const state = this.#stateStorage.get(this.#slots.state);
if (state === null || state.principal.toText() !== identity.getPrincipal().toText()) {
identity.dispose();
await this.#dropSession();
return;
}
this.#identity = identity;
}

Expand Down
219 changes: 219 additions & 0 deletions src/client/cookie-state-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { Principal } from '@icp-sdk/core/principal';
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';

/**
* Whether a hostname is loopback, which browsers treat as a secure context, so a
* cookie set there needs no `Secure` attribute.
*/
const isLoopbackHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';

export interface CookieStateStorageOptions {
/**
* Domain to scope the cookie to, e.g. `example.com` so `a.example.com` and
* `b.example.com` share one sign-in.
*
* Must be the current host or a domain above it: the browser rejects a cookie
* scoped to anything else, including a sibling subdomain, and refuses a public
* suffix — so an over-broad value fails rather than leaking to unrelated sites.
* Nothing needs to be served at the domain; it is only a scope.
*/
domain: string;
}

/**
* The state of a sign-in, in a cookie, so every sibling of a domain shares it.
*
* A cookie is the only thing that crosses between origins, which is what lets one
* sign-out end them all: the record is the domain's rather than this origin's, so
* removing it is what tells a sibling the sign-in is over. It carries no chain
* and no key, so a sibling acting on it asks the identity provider to re-issue
* rather than treating it as proof.
* @see implements {@link StateStorage}
*/
export class CookieStateStorage implements StateStorage {
/**
* A sign-in kept here may be resumed without a ceremony.
*
* This is the store whose record reaches past one origin, so it is the store
* whose siblings arrive holding no credential and needing the identity
* provider to remember the session they can be given one from. Asking a
* provider to keep a sign-in is only worth the persistence where somebody is
* going to come back to it, and here somebody will.
*/
public readonly resumable = true;

readonly #attributes: string;
#subscribers = new Map<string, Set<() => void>>();

// The cookie is the domain's and says who is signed in; this says whether it
// was this origin that acquired a credential for them. A cookie cannot carry
// that — every sibling reads the same bytes — so it is kept per origin and
// composed in on read, under the same key the cookie uses.
readonly #local = new LocalStateStorage();

constructor(options: CookieStateStorageOptions) {
// Both sides, because this decides whether to skip the check below and drop
// the `Domain` attribute. Taken from the configured domain alone, a
// `domain: 'localhost'` on a real host would do both — accept the
// misconfiguration and write a host-only cookie — which is the silent
// failure the check exists to prevent.
const hostname = globalThis.location?.hostname;
const loopback =
isLoopbackHost(options.domain) && (hostname === undefined || isLoopbackHost(hostname));

// A browser silently ignores `document.cookie` when the domain is not this
// host or one above it, and this cookie is the state itself, so the sign-in
// would appear to be over the moment it was written. Refusing here names the
// cause; the silent version does not.
if (
!loopback &&
hostname !== undefined &&
hostname !== options.domain &&
!hostname.endsWith(`.${options.domain}`)
) {
throw new Error(
`A cookie cannot be scoped to '${options.domain}' from '${hostname}': the domain has to be this host or one above it`,
);
}

const secure = globalThis.location?.protocol === 'https:';
this.#attributes = [
// Host-only on loopback: browsers reject `Domain=localhost`, and a
// host-only cookie already spans ports, which local multi-origin needs.
...(loopback ? [] : [`Domain=${options.domain}`]),
'Path=/',
'SameSite=Lax',
// Loopback is a secure context, but a `Secure` cookie over http is not
// reliably accepted.
...(secure ? ['Secure'] : []),
].join('; ');
}

public get(key: string): SessionState | null {
const raw = readCookie(key);
if (raw === null) return null;

const [principalText, expiration] = raw.split('|');
if (principalText === undefined || expiration === undefined) return null;
try {
const principal = Principal.fromText(principalText);
// Compared, not merely counted: a record can outlive the sign-in it was
// written for — an expired one is kept on purpose, so an application can
// say the session ended — and a sibling signing in as someone else then
// publishes a cookie this origin has no credential for. Asking only whether
// some local record exists would read that as held.
const local = this.#local.get(key);
return {
principal,
expiration: BigInt(expiration),
held: local !== null && local.principal.compareTo(principal) === 'eq',
};
} catch {
return null;
}
}

public set(key: string, state: Omit<SessionState, 'held'>): void {
const seconds = Number((state.expiration - BigInt(Date.now()) * 1_000_000n) / 1_000_000_000n);
if (seconds <= 0) {
// Already over, so there is no state to publish: writing it would announce
// a sign-in that has ended.
this.remove(key);
return;
}
// The local record first, so nothing reads the cookie as held before it is.
this.#local.set(key, state);
const payload = `${state.principal.toText()}|${state.expiration.toString()}`;
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async, and this record is read synchronously alongside isAuthenticated().
document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${seconds}`;
this.#fire(key);
}

public remove(key: string): void {
this.#local.remove(key);
// biome-ignore lint/suspicious/noDocumentCookie: same reason as set().
document.cookie = `${encodeURIComponent(key)}=; ${this.#attributes}; Max-Age=0`;
this.#fire(key);
}

/**
* Drops this origin's claim on the sign-in, leaving the cookie for the siblings.
*
* An origin whose chain turns out to be dead cannot tell a revoked session from
* one a sibling replaced by signing in, and in the second case that sibling
* wrote this cookie a moment ago. So the record stands and this origin reads it
* as not held, which is what sends it to acquire one of its own.
*/
public discard(key: string): void {
this.#local.remove(key);
this.#fire(key);
}

/**
* Fires when the state changes, including when a sibling subdomain changes it.
*
* `document.cookie` raises no event and no `BroadcastChannel` crosses origins,
* so a sibling's sign-out is visible only by looking: the Cookie Store API
* where the browser has it, and otherwise a re-check whenever this page is
* shown or its window regains focus, which is when the user is about to act on
* the answer.
*/
public subscribe(key: string, listener: () => void): () => void {
// One logical change can arrive from several sources at once, so each is
// routed through a check that fires only when what is stored actually
// changed since the last one.
let last = readCookie(key);
const check = (): void => {
const now = readCookie(key);
if (now !== last) {
last = now;
listener();
}
};
const listeners = this.#subscribers.get(key) ?? new Set();
listeners.add(check);
this.#subscribers.set(key, listeners);

const onVisible = (): void => {
if (document.visibilityState === 'visible') check();
};
document.addEventListener('visibilitychange', onVisible);
globalThis.addEventListener('pageshow', check);
globalThis.addEventListener('focus', check);

const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore;
cookieStore?.addEventListener('change', check);

return () => {
listeners.delete(check);
document.removeEventListener('visibilitychange', onVisible);
globalThis.removeEventListener('pageshow', check);
globalThis.removeEventListener('focus', check);
cookieStore?.removeEventListener('change', check);
};
}

#fire(key: string): void {
for (const check of [...(this.#subscribers.get(key) ?? [])]) check();
}
}

/** Reads a cookie value by name, or `null` if absent. */
function readCookie(name: string): string | null {
for (const entry of document.cookie.split(';')) {
const separator = entry.indexOf('=');
if (separator === -1) continue;
// Any cookie on this domain lands here, including ones this library did not
// write. A stray `%` makes `decodeURIComponent` throw, and one unrelated
// cookie must not be able to break reading the state.
try {
if (decodeURIComponent(entry.slice(0, separator).trim()) !== name) continue;
return decodeURIComponent(entry.slice(separator + 1).trim());
} catch {}
}
return null;
}
4 changes: 4 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

export { type AppDelegationSource, SessionGoneError } from './app-delegation-source.js';
export * from './auth-client.js';
export {
CookieStateStorage,
type CookieStateStorageOptions,
} from './cookie-state-storage.js';
export type { Credential, CredentialStorage } from './credential-storage.js';
export { DB_VERSION, type DBCreateOptions, IdbKeyVal } from './db.js';
export { watchActivity, watchForeground } from './foreground-refresh.js';
Expand Down
Loading
Loading