diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e240c..aae7b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. If you have any questions, see the issues and discussions (e.g. [#18](https://github.com/uvdsl/solid-oidc-client-browser/issues/18)) +## [Unreleased] + +### ✨ Features (Added) + +- **Named sessions**: an optional static `name` in `SessionOptions` lets one app hold several sessions on the same origin (e.g. two Pods side by side). A named session namespaces its pending-login `sessionStorage` keys (`:csrf_token`, ...), gets its own IndexedDB (`soidc:`), and - in the `/web` build - its own `SharedWorker`. The default (unnamed) session is byte-identical to previous releases: same keys, same `soidc` database, same worker URL, so existing persisted sessions survive the upgrade. Named sessions are namespaced for bookkeeping, NOT isolated from each other - the same-origin trust model is unchanged, and they are no substitute for deploying distinct apps on distinct origins. +- **Worker/page storage handshake**: the page sends the database name it expects with `SCHEDULE`/`REFRESH`; a worker bound to a different database answers with an error instead of serving tokens from the wrong session. Named sessions also fetch the worker script with a compat-version query so a stale HTTP-cached pre-namespacing worker can never serve a named session. +- A named session that receives an authorization response while it has no login pending ignores it with a `console.warn`, leaving the response for the session that started the flow. The default session keeps its existing loud error. +- A runnable example, `examples/named-sessions.html`: two independent logins on one page. + ## [0.2.3] - 2026-06-18 ### πŸ› Fixed diff --git a/README.md b/README.md index 97e0968..22a5368 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,25 @@ There is a small library that provides [Solid Requests](https://github.com/uvdsl If you don't want to dabble with parsing the retrieved RDF data manually, check out the [Solid RDF Store](https://github.com/uvdsl/solid-rdf-store). You can use the `session` object in that store to let the store fetch (authenticated) RDF data from the Web and have reactive query results, i.e. results that can update reactively when query underlying data changes. +#### Multiple sessions in one app (named sessions) + +One app may need to hold several user identities at once - say, comparing two Pods side by side. +Give each session a static name: + +```ts +const left = new Session({ redirect_uris: [window.location.href], client_name: "My Solid App" }, { name: "left" }); +const right = new Session({ redirect_uris: [window.location.href], client_name: "My Solid App" }, { name: "right" }); +``` + +A named session keeps its own login state, its own IndexedDB, and its own refresh worker, so the sessions never interfere with each other's tokens. +On the redirect back from the identity provider, call `handleRedirectFromLogin()` on the session that started the login; a named session with no login pending ignores the redirect (and logs a warning), so the initiating session can consume it. + +A runnable example: [`examples/named-sessions.html`](examples/named-sessions.html) β€” `npm run build`, serve the repo root, open the page, and sign the two panels in. + +The name is a static application constant matching `[A-Za-z0-9_-]+` - never derive it from user input. +Named sessions are namespaced for **bookkeeping**, not isolated from each other: every script on the origin can still reach every session's storage, exactly as with a single session. +They are for one app holding several identities - **not** for separating distinct apps on one origin (see the security considerations below). + ## Security Considerations @@ -121,10 +140,9 @@ If you think that the two Solid Apps should still have distinct `client_id`, the To summarise the point: The question on multiple apps on the same origin is to be answered by considering the conceptual relation of the multiple apps with regards to the browers' security mechansims. -We - as in this library - cannot manage distinct sessions via the `IndexedDB API` securely. Not because we do not want to but because the browser does not provide us a more granular and secure (!) option. Of course, we could provide different databases for different paths on an origin. -But all these databases would still be accessible from any path on the origin. +We - as in this library - cannot **isolate** distinct sessions via the `IndexedDB API`. Not because we do not want to but because the browser does not provide us a more granular and secure (!) option. Named sessions (see Quick Start) give each session its own database, but all these databases are still accessible from any path on the origin. -Do you really want distinct logins and distinct sessions? This is not a question of concept but a question of security. You MUST deploy the apps on different origins. +Named sessions are therefore bookkeeping for one app that holds several identities, within one security context - they are no security boundary between apps. Do you really want distinct apps with distinct logins? This is not a question of concept but a question of security. You MUST deploy the apps on different origins. --- diff --git a/examples/named-sessions.html b/examples/named-sessions.html new file mode 100644 index 0000000..8992a1c --- /dev/null +++ b/examples/named-sessions.html @@ -0,0 +1,106 @@ + + + + + + +Two sessions, one page β€” named sessions + + + +

Two sessions, one page

+

Each panel is its own named session β€” left and right β€” +with its own login, its own storage and its own token refresh. Sign the panels in +with different accounts or providers to hold two identities at once.

+ +
+
+

Left identity

+ + +

+ + + +

+

Signed out.

+
+ + +
+ + + + diff --git a/src/core/AuthorizationCodeGrant.ts b/src/core/AuthorizationCodeGrant.ts index 2577cae..764504f 100644 --- a/src/core/AuthorizationCodeGrant.ts +++ b/src/core/AuthorizationCodeGrant.ts @@ -2,14 +2,17 @@ import { createRemoteJWKSet, generateKeyPair, jwtVerify, exportJWK, SignJWT, Gen import { requestDynamicClientRegistration } from "./DynamicClientRegistration"; import { ClientDetails, DynamicRegistrationClientDetails, IdentityProviderDetails, SessionInformation, TokenDetails } from "./SessionInformation"; import { SessionDatabase } from "./SessionDatabase"; +import { storageKey } from "./SessionName"; /** * Login with the idp, using a provided `client_id` or dynamic client registration if none provided. * * @param idp * @param redirect_uri + * @param client_details + * @param session_name optional session name namespacing this flow's sessionStorage keys */ -const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails) => { +const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails, session_name?: string) => { // RFC 6749 - Section 3.1.2 - sanitize redirect_uri const redirect_uri_ = new URL(redirect_uri); const redirect_uri_sane = redirect_uri_.origin + redirect_uri_.pathname + redirect_uri_.search; @@ -31,15 +34,15 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail "RFC 9207 - iss !== idp - " + issuer + " !== " + idp ); } - sessionStorage.setItem("idp", issuer); + sessionStorage.setItem(storageKey(session_name, "idp"), issuer); // remember token endpoint sessionStorage.setItem( - "token_endpoint", + storageKey(session_name, "token_endpoint"), openid_configuration["token_endpoint"] ); // remember jwks_uri for later token verification sessionStorage.setItem( - "jwks_uri", + storageKey(session_name, "jwks_uri"), openid_configuration["jwks_uri"] ); @@ -66,16 +69,16 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail try { new URL(client_id) } catch { - sessionStorage.setItem("client_id", client_id); + sessionStorage.setItem(storageKey(session_name, "client_id"), client_id); } // RFC 7636 PKCE, remember code verifer const { pkce_code_verifier, pkce_code_challenge } = await getPKCEcode(); - sessionStorage.setItem("pkce_code_verifier", pkce_code_verifier); + sessionStorage.setItem(storageKey(session_name, "pkce_code_verifier"), pkce_code_verifier); // RFC 6749 OAuth 2.0 - CSRF token const csrf_token = window.crypto.randomUUID(); - sessionStorage.setItem("csrf_token", csrf_token); + sessionStorage.setItem(storageKey(session_name, "csrf_token"), csrf_token); // redirect to idp const redirect_to_idp = @@ -119,7 +122,7 @@ const getPKCEcode = async () => { * URL contains authrization code, issuer (idp) and state (csrf token), * get an access token for the authrization code. */ -const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase) => { +const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase, session_name?: string) => { const url = new URL(window.location.href); // authorization code const authorization_code = url.searchParams.get("code"); @@ -127,17 +130,29 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses if (authorization_code === null) { return { clientDetails: client_details } as SessionInformation; } + // A named session with no pending login (no stored csrf token) does not + // consume the authorization response: with several sessions on one page, + // the response belongs to whichever session started the flow. The URL is + // left untouched so that session can still consume it - but say so, since + // an authorization response that nobody consumes is worth noticing. + // The default (unnamed) session keeps its loud failure below, unchanged. + if (session_name && sessionStorage.getItem(storageKey(session_name, "csrf_token")) === null) { + console.warn( + `solid-oidc-client-browser: session '${session_name}' ignores an authorization response - no login pending` + ); + return { clientDetails: client_details } as SessionInformation; + } // RFC 9207 issuer check - const idp = sessionStorage.getItem("idp"); + const idp = sessionStorage.getItem(storageKey(session_name, "idp")); if (idp === null || url.searchParams.get("iss") !== idp) { throw new Error( "RFC 9207 - iss !== idp - " + url.searchParams.get("iss") + " !== " + idp ); } // RFC 6749 OAuth 2.0 - if (url.searchParams.get("state") !== sessionStorage.getItem("csrf_token")) { + if (url.searchParams.get("state") !== sessionStorage.getItem(storageKey(session_name, "csrf_token"))) { throw new Error( - "RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem("csrf_token") + "RFC 6749 - state !== csrf_token - " + url.searchParams.get("state") + " !== " + sessionStorage.getItem(storageKey(session_name, "csrf_token")) ); } // remove redirect query parameters from URL @@ -147,19 +162,19 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses window.history.pushState({}, document.title, url.toString()); // prepare token request - const pkce_code_verifier = sessionStorage.getItem("pkce_code_verifier"); + const pkce_code_verifier = sessionStorage.getItem(storageKey(session_name, "pkce_code_verifier")); if (pkce_code_verifier === null) { throw new Error( "Access Token Request preparation - Could not find in sessionStorage: pkce_code_verifier" ); } - const client_id = client_details?.client_id || sessionStorage.getItem("client_id"); + const client_id = client_details?.client_id || sessionStorage.getItem(storageKey(session_name, "client_id")); if (!client_id) { throw new Error( "Access Token Request preparation - Could not find in sessionStorage: client_id (dynamic registration)" ); } - const token_endpoint = sessionStorage.getItem("token_endpoint"); + const token_endpoint = sessionStorage.getItem(storageKey(session_name, "token_endpoint")); if (token_endpoint === null) { throw new Error( "Access Token Request preparation - Could not find in sessionStorage: token_endpoint" @@ -187,7 +202,7 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses // verify access_token // ! Solid-OIDC specification says it should be a dpop-bound `id token` but implementations provide a dpop-bound `access token` const accessToken = token_response["access_token"]; - const jwks_uri = sessionStorage.getItem("jwks_uri"); + const jwks_uri = sessionStorage.getItem(storageKey(session_name, "jwks_uri")); if (jwks_uri === null) { throw new Error( "Access Token validation preparation - Could not find in sessionStorage: jwks_uri" @@ -234,12 +249,12 @@ const onIncomingRedirect = async (client_details?: ClientDetails, database?: Ses } // clean session storage - sessionStorage.removeItem("csrf_token"); - sessionStorage.removeItem("pkce_code_verifier"); - sessionStorage.removeItem("idp"); - sessionStorage.removeItem("jwks_uri"); - sessionStorage.removeItem("token_endpoint"); - sessionStorage.removeItem("client_id"); + sessionStorage.removeItem(storageKey(session_name, "csrf_token")); + sessionStorage.removeItem(storageKey(session_name, "pkce_code_verifier")); + sessionStorage.removeItem(storageKey(session_name, "idp")); + sessionStorage.removeItem(storageKey(session_name, "jwks_uri")); + sessionStorage.removeItem(storageKey(session_name, "token_endpoint")); + sessionStorage.removeItem(storageKey(session_name, "client_id")); // return session information return { diff --git a/src/core/Session.ts b/src/core/Session.ts index 404e32f..df95e5b 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -2,11 +2,22 @@ import { SignJWT, decodeJwt, exportJWK } from "jose"; import { redirectForLogin, onIncomingRedirect } from "./AuthorizationCodeGrant"; import { renewTokens } from "./RefreshTokenGrant"; import { SessionDatabase } from "./SessionDatabase"; +import { normalizeSessionName } from "./SessionName"; import { DynamicRegistrationClientDetails, DereferencableIdClientDetails, SessionInformation, TokenDetails } from "./SessionInformation"; export interface SessionOptions { database?: SessionDatabase + /** + * Optional session name, for running more than one session on the same + * origin. A static, application-chosen constant (never user input), + * matching `[A-Za-z0-9_-]+`. It namespaces this session's browser storage + * from other sessions'. Named sessions are namespaced for bookkeeping, + * NOT isolated from each other: every script on the origin remains fully + * trusted, exactly as with a single session. + * Omitted (or empty): the default, unnamed session. + */ + name?: string; onSessionStateChange?: (event?: Event) => void; onSessionExpirationWarning?: (event?: Event) => void; onSessionExpiration?: (event?: Event) => void; @@ -95,6 +106,7 @@ export class SessionCore extends EventTarget implements Session { private information: SessionInformation; private database?: SessionDatabase; + private name_?: string; protected refreshPromise?: Promise; protected resolveRefresh?: (() => void); @@ -105,6 +117,7 @@ export class SessionCore extends EventTarget implements Session { this.authFetch = this.authFetch.bind(this); this.information = { clientDetails } as SessionInformation; this.database = sessionOptions?.database + this.name_ = normalizeSessionName(sessionOptions?.name); if (sessionOptions?.onSessionStateChange) this.addEventListener(SessionEvents.STATE_CHANGE, (event: Event) => sessionOptions.onSessionStateChange?.(event)) if (sessionOptions?.onSessionExpirationWarning) @@ -114,7 +127,7 @@ export class SessionCore extends EventTarget implements Session { } async login(idp: string, redirect_uri: string) { - await redirectForLogin(idp, redirect_uri, this.information.clientDetails) + await redirectForLogin(idp, redirect_uri, this.information.clientDetails, this.name_) } /** @@ -125,7 +138,7 @@ export class SessionCore extends EventTarget implements Session { */ async handleRedirectFromLogin() { // Redirect after Authorization Code Grant // memory via sessionStorage - const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database); + const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database, this.name_); // no session - we remain unauthenticated if (!newSessionInfo.tokenDetails) return; // we got a session @@ -292,6 +305,13 @@ export class SessionCore extends EventTarget implements Session { return this.webId_; } + /** + * The normalized session name, or `undefined` for the default session. + */ + get name() { + return this.name_; + } + isExpired() { if (!this.exp_) return true; return this._isTokenExpired(this.exp_); diff --git a/src/core/SessionName.ts b/src/core/SessionName.ts new file mode 100644 index 0000000..3b31425 --- /dev/null +++ b/src/core/SessionName.ts @@ -0,0 +1,50 @@ +/** + * Support for running more than one session on the same origin. + * + * A session name is a static, application-chosen constant (never user input) + * that namespaces one session's browser storage from another's: + * the sessionStorage keys of a pending login, and the IndexedDB database + * (plus the SharedWorker, in the `/web` build) of an established session. + * + * Named sessions are namespaced for bookkeeping, NOT isolated from each + * other: every script running on the origin remains fully trusted, exactly + * as with a single session. + */ +export const SESSION_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** + * Normalizes a session name. + * `undefined` and `''` both mean the default, unnamed session, + * so that an accidentally empty name cannot fork the storage namespace. + * + * @param name the session name to normalize + * @returns the name, or `undefined` for the default session + * @throws TypeError when the name does not match {@link SESSION_NAME_PATTERN} + */ +export const normalizeSessionName = (name?: string): string | undefined => { + if (name === undefined || name === "") return undefined; + if (!SESSION_NAME_PATTERN.test(name)) { + throw new TypeError( + `Invalid session name '${name}' - expected a static application constant matching ${SESSION_NAME_PATTERN}` + ); + } + return name; +}; + +/** + * The sessionStorage key for a given session name, + * e.g. `csrf_token` (default session) or `left:csrf_token` (session `left`). + * The suffix set is fixed, so prefixed keys of one session + * can never collide with those of another. + */ +export const storageKey = (name: string | undefined, key: string): string => + name ? `${name}:${key}` : key; + +/** + * The IndexedDB database name for a given session name, + * e.g. `soidc` (default session) or `soidc:left` (session `left`). + * One database per session: `logout()` clears a whole database, + * and must only ever clear its own session. + */ +export const databaseName = (name?: string): string => + name ? `soidc:${name}` : "soidc"; diff --git a/src/web/RefreshWorker.ts b/src/web/RefreshWorker.ts index 0988765..9647bf6 100644 --- a/src/web/RefreshWorker.ts +++ b/src/web/RefreshWorker.ts @@ -1,12 +1,14 @@ import { decodeJwt } from "jose"; import { renewTokens } from "../core/RefreshTokenGrant"; import { TokenDetails } from "../core/SessionInformation"; +import { databaseName } from "../core/SessionName"; import { SessionIDB } from "./SessionDatabase"; import { SessionDatabase } from "../core/SessionDatabase"; import { RefreshMessageTypes } from "./RefreshMessageTypes"; interface SharedWorker { // to make tsc happy onconnect: (event: MessageEvent) => void; + name: string; } declare const self: SharedWorker; // to make tsc happy @@ -23,9 +25,12 @@ let refresher: Refresher; self.onconnect = (event: MessageEvent) => { const port = event.ports[0]; ports.add(port); - // lazy init + // lazy init - a named worker (SharedWorker identity includes its name) + // is bound to that session's own database; the unnamed worker (name '') + // keeps the default database. if (!refresher) { - refresher = new Refresher(broadcast, new SessionIDB()); + const dbName = databaseName(self.name || undefined); + refresher = new Refresher(broadcast, new SessionIDB(dbName), dbName); } // handle messages port.onmessage = (event: MessageEvent) => { @@ -35,7 +40,7 @@ self.onconnect = (event: MessageEvent) => { refresher.handleSchedule(payload); break; case RefreshMessageTypes.REFRESH: - refresher.handleRefresh(port); + refresher.handleRefresh(port, payload?.dbName); break; case RefreshMessageTypes.STOP: refresher.handleStop(); @@ -58,20 +63,42 @@ export class Refresher { private broadcast: (message: any) => void; private database: SessionDatabase; + private dbName?: string; private refreshPromise?: Promise; constructor( broadcast: (message: string) => void, - database: SessionDatabase + database: SessionDatabase, + dbName?: string ) { this.broadcast = broadcast; this.database = database; + this.dbName = dbName; } - async handleSchedule(tokenDetails: TokenDetails) { - this.tokenDetails = tokenDetails; + /** + * A page expecting one storage namespace must never be served tokens from + * another. A mismatch means a stale HTTP-cached copy of this worker script + * is running for a named session, or page and worker have drifted in how + * they derive the database name. Fail loudly instead of serving tokens. + */ + private guardDbName(expected?: string): boolean { + if (expected && this.dbName && expected !== this.dbName) { + this.broadcast({ + type: RefreshMessageTypes.ERROR_ON_REFRESH, + error: `RefreshWorker storage mismatch: the page expects '${expected}' but this worker is bound to '${this.dbName}'. A stale cached RefreshWorker.js can cause this - reload the page.` + }); + return false; + } + return true; + } + + async handleSchedule(payload: TokenDetails & { dbName?: string }) { + const { dbName, ...tokenDetails } = payload as any; + if (!this.guardDbName(dbName)) return; + this.tokenDetails = tokenDetails as TokenDetails; this.exp = decodeJwt(this.tokenDetails.access_token).exp; this.broadcast({ type: RefreshMessageTypes.TOKEN_DETAILS, @@ -82,7 +109,8 @@ export class Refresher { this.timersAreRunning = true; } - async handleRefresh(requestingPort: any): Promise { + async handleRefresh(requestingPort: any, expectedDbName?: string): Promise { + if (!this.guardDbName(expectedDbName)) return; if (this.tokenDetails && this.exp && !this.isTokenExpired(this.exp)) { console.log(`[RefreshWorker] Providing current tokens`); requestingPort.postMessage({ diff --git a/src/web/RefreshWorkerUrl.ts b/src/web/RefreshWorkerUrl.ts index a480632..3bdf9bd 100644 --- a/src/web/RefreshWorkerUrl.ts +++ b/src/web/RefreshWorkerUrl.ts @@ -1,2 +1,13 @@ // extracting this from Session.ts such that the jest tests would compile :) -export const getWorkerUrl = () => new URL('./RefreshWorker.js', import.meta.url); \ No newline at end of file + +// Bump when the worker's storage derivation or message protocol changes. +// The version query busts stale HTTP caches for NAMED sessions: a named +// session must never run pre-namespacing worker bytes, which would bind it +// to the default database. The unnamed session keeps the unversioned URL, +// byte-identical to previous releases. +export const WORKER_COMPAT_VERSION = 1; + +export const getWorkerUrl = (sessionName?: string) => + sessionName + ? new URL(`./RefreshWorker.js?v=${WORKER_COMPAT_VERSION}`, import.meta.url) + : new URL('./RefreshWorker.js', import.meta.url); diff --git a/src/web/Session.ts b/src/web/Session.ts index 338d2bd..084a7ff 100644 --- a/src/web/Session.ts +++ b/src/web/Session.ts @@ -1,32 +1,45 @@ import { DereferencableIdClientDetails, DynamicRegistrationClientDetails } from '../core'; import { SessionOptions, SessionCore } from '../core/Session'; +import { databaseName, normalizeSessionName } from '../core/SessionName'; import { getWorkerUrl } from './RefreshWorkerUrl'; import { RefreshMessageTypes } from './RefreshMessageTypes'; import { SessionIDB } from './SessionDatabase'; // Any provided database via SessionOptions will be ignored. -// Database will be an IndexedDB. +// Database will be an IndexedDB, one database per session name. export interface WebWorkerSessionOptions extends SessionOptions { workerUrl?: string | URL; } /** * This Session provides background token refreshing using a Web Worker. + * A named session (see {@link SessionOptions.name}) gets its own IndexedDB + * and its own SharedWorker, so several sessions can coexist on one origin. */ export class WebWorkerSession extends SessionCore { private worker: SharedWorker; + // The database name this session expects its worker to be bound to. + // Sent with SCHEDULE/REFRESH so a mismatched worker fails loudly + // instead of serving tokens from another session's database. + private dbName: string; constructor( clientDetails?: DereferencableIdClientDetails | DynamicRegistrationClientDetails, sessionOptions?: WebWorkerSessionOptions ) { - const database = new SessionIDB(); - const options = { ...sessionOptions, database }; + const name = normalizeSessionName(sessionOptions?.name); + const database = new SessionIDB(databaseName(name)); + const options = { ...sessionOptions, name, database }; super(clientDetails, options); + this.dbName = databaseName(name); // Allow consumer to provide worker URL, or use default - const workerUrl = sessionOptions?.workerUrl ?? getWorkerUrl() - this.worker = new SharedWorker(workerUrl, { type: 'module' }); + const workerUrl = sessionOptions?.workerUrl ?? getWorkerUrl(name) + // SharedWorker identity is (origin, URL, name): a named session gets + // its own worker, bound to its own database via the worker's self.name. + this.worker = name + ? new SharedWorker(workerUrl, { type: 'module', name }) + : new SharedWorker(workerUrl, { type: 'module' }); this.worker.port.onmessage = (event) => { this.handleWorkerMessage(event.data).catch(console.error); }; @@ -79,7 +92,7 @@ export class WebWorkerSession extends SessionCore { if (this.isActive) { // If login was successful, tell the worker to schedule refreshing this.worker.port.postMessage({ type: RefreshMessageTypes.SCHEDULE, - payload: { ...this.getTokenDetails(), expires_in: this.getExpiresIn() } + payload: { ...this.getTokenDetails(), expires_in: this.getExpiresIn(), dbName: this.dbName } }); } } @@ -92,7 +105,7 @@ export class WebWorkerSession extends SessionCore { this.resolveRefresh = resolve; this.rejectRefresh = reject; }); - this.worker.port.postMessage({ type: RefreshMessageTypes.REFRESH }); + this.worker.port.postMessage({ type: RefreshMessageTypes.REFRESH, payload: { dbName: this.dbName } }); return this.refreshPromise; } diff --git a/tests/core/AuthorizationCodeGrant.test.ts b/tests/core/AuthorizationCodeGrant.test.ts index d6b43ed..e63b084 100644 --- a/tests/core/AuthorizationCodeGrant.test.ts +++ b/tests/core/AuthorizationCodeGrant.test.ts @@ -481,3 +481,107 @@ describe('onIncomingRedirect', () => { }); + +describe('named sessions (session_name)', () => { + const mockOpenIdConfig = { + issuer: 'https://idp.example.com/', + authorization_endpoint: 'https://idp.example.com/auth', + token_endpoint: 'https://idp.example.com/token', + jwks_uri: 'https://idp.example.com/jwks', + registration_endpoint: 'https://idp.example.com/register', + }; + + const mockTokenResponse = { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + }; + + const mockJwtPayload = { + iss: 'https://idp.example.com/', + aud: 'solid', + client_id: 'test-client', + cnf: { jkt: 'mock-thumbprint' }, + }; + + // Pre-populate a pending login for session `left`, + // as if redirectForLogin('...', '...', details, 'left') had run. + const populatePendingLogin = (prefix: string, csrf: string) => { + sessionStorage.setItem(`${prefix}:idp`, 'https://idp.example.com/'); + sessionStorage.setItem(`${prefix}:csrf_token`, csrf); + sessionStorage.setItem(`${prefix}:pkce_code_verifier`, 'mock-pkce-verifier'); + sessionStorage.setItem(`${prefix}:token_endpoint`, 'https://idp.example.com/token'); + sessionStorage.setItem(`${prefix}:jwks_uri`, 'https://idp.example.com/jwks'); + }; + + it('redirectForLogin writes ONLY prefixed keys for a named session', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockOpenIdConfig), + }); + + await redirectForLogin('https://idp.example.com/', 'https://app.example.com/redirect', { client_id: 'test-client' } as ClientDetails, 'left'); + + expect(sessionStorage.setItem).toHaveBeenCalledWith('left:idp', 'https://idp.example.com/'); + expect(sessionStorage.setItem).toHaveBeenCalledWith('left:token_endpoint', 'https://idp.example.com/token'); + expect(sessionStorage.setItem).toHaveBeenCalledWith('left:pkce_code_verifier', expect.any(String)); + expect(sessionStorage.setItem).toHaveBeenCalledWith('left:csrf_token', 'mock-random-uuid'); + // and never the bare (default-session) keys + expect(sessionStorage.setItem).not.toHaveBeenCalledWith('idp', expect.anything()); + expect(sessionStorage.setItem).not.toHaveBeenCalledWith('csrf_token', expect.anything()); + }); + + it('onIncomingRedirect consumes a named pending login and cleans exactly its own keys', async () => { + populatePendingLogin('left', 'left-csrf'); + populatePendingLogin('right', 'right-csrf'); // a second, concurrent pending login + mockLocation('https://app.example.com/redirect?code=auth-code&state=left-csrf&iss=https://idp.example.com/'); + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse), + }); + (jose.jwtVerify as jest.Mock).mockResolvedValueOnce({ payload: mockJwtPayload }); + (jose.calculateJwkThumbprint as jest.Mock).mockResolvedValueOnce('mock-thumbprint'); + + const result = await onIncomingRedirect({ client_id: 'test-client' } as ClientDetails, undefined, 'left'); + + expect(result.tokenDetails?.access_token).toBe('mock-access-token'); + // left's pending login is cleaned ... + expect(sessionStorage.getItem('left:csrf_token')).toBeNull(); + expect(sessionStorage.getItem('left:pkce_code_verifier')).toBeNull(); + // ... while right's pending login is untouched + expect(sessionStorage.getItem('right:csrf_token')).toBe('right-csrf'); + expect(sessionStorage.getItem('right:pkce_code_verifier')).toBe('mock-pkce-verifier'); + }); + + it('a named session with no pending login ignores an authorization response without consuming it', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { }); + populatePendingLogin('left', 'left-csrf'); + mockLocation('https://app.example.com/redirect?code=auth-code&state=left-csrf&iss=https://idp.example.com/'); + + // `right` has NO pending login - it must not consume left's response + const result = await onIncomingRedirect({ client_id: 'test-client' } as ClientDetails, undefined, 'right'); + + expect(result.tokenDetails).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); // no token exchange + expect(warn).toHaveBeenCalledWith(expect.stringContaining("session 'right'")); + // left's pending login survives, so left can still consume the response + expect(sessionStorage.getItem('left:csrf_token')).toBe('left-csrf'); + warn.mockRestore(); + }); + + it('a named session with a pending login still throws on a state mismatch', async () => { + populatePendingLogin('left', 'left-csrf'); + mockLocation('https://app.example.com/redirect?code=auth-code&state=WRONG&iss=https://idp.example.com/'); + + await expect(onIncomingRedirect({ client_id: 'test-client' } as ClientDetails, undefined, 'left')) + .rejects.toThrow('RFC 6749 - state !== csrf_token'); + }); + + it('the default (unnamed) session keeps its loud failure on an unsolicited response', async () => { + // no pending login at all for the default session + mockLocation('https://app.example.com/redirect?code=auth-code&state=whatever&iss=https://idp.example.com/'); + + await expect(onIncomingRedirect({ client_id: 'test-client' } as ClientDetails)) + .rejects.toThrow('RFC 9207 - iss !== idp'); + }); +}); + diff --git a/tests/core/Session.test.ts b/tests/core/Session.test.ts index 693ea15..00e248b 100644 --- a/tests/core/Session.test.ts +++ b/tests/core/Session.test.ts @@ -137,7 +137,8 @@ describe('SessionCore', () => { expect(AuthCodeGrant.redirectForLogin).toHaveBeenCalledWith( 'https://idp.example', 'https://app.example/callback', - mockClientDetails + mockClientDetails, + undefined // the default, unnamed session ); }); }); @@ -154,7 +155,7 @@ describe('SessionCore', () => { // Assert expect(AuthCodeGrant.onIncomingRedirect).toHaveBeenCalledTimes(1); - expect(AuthCodeGrant.onIncomingRedirect).toHaveBeenCalledWith(mockClientDetails, mockDb); + expect(AuthCodeGrant.onIncomingRedirect).toHaveBeenCalledWith(mockClientDetails, mockDb, undefined); expect(session.isActive).toBe(true); expect(session.webId).toBe('https://alice.example/card#me'); expect((session as any).information).toEqual(mockSessionInfo); diff --git a/tests/core/SessionName.test.ts b/tests/core/SessionName.test.ts new file mode 100644 index 0000000..0f8f289 --- /dev/null +++ b/tests/core/SessionName.test.ts @@ -0,0 +1,58 @@ +import { normalizeSessionName, storageKey, databaseName, SESSION_NAME_PATTERN } from '../../src/core/SessionName'; + +describe('normalizeSessionName', () => { + it('returns undefined for undefined (the default session)', () => { + expect(normalizeSessionName(undefined)).toBeUndefined(); + }); + + it('returns undefined for the empty string, so an empty name cannot fork the storage namespace', () => { + expect(normalizeSessionName('')).toBeUndefined(); + }); + + it('returns a valid name unchanged', () => { + expect(normalizeSessionName('left')).toBe('left'); + expect(normalizeSessionName('my-App_2')).toBe('my-App_2'); + }); + + it('throws on a name outside the allowed charset', () => { + expect(() => normalizeSessionName('left:idp')).toThrow(TypeError); + expect(() => normalizeSessionName('a b')).toThrow(TypeError); + expect(() => normalizeSessionName('soidc:x')).toThrow(TypeError); + expect(() => normalizeSessionName('ΓΌber')).toThrow(TypeError); + }); + + it('exposes the pattern names are validated against', () => { + expect(SESSION_NAME_PATTERN.test('ok-name')).toBe(true); + expect(SESSION_NAME_PATTERN.test('not ok')).toBe(false); + }); +}); + +describe('storageKey', () => { + it('keeps the bare key for the default session - byte-identical to previous releases', () => { + expect(storageKey(undefined, 'csrf_token')).toBe('csrf_token'); + expect(storageKey(undefined, 'idp')).toBe('idp'); + }); + + it('prefixes the key for a named session', () => { + expect(storageKey('left', 'csrf_token')).toBe('left:csrf_token'); + }); + + it('cannot collide across names: every key ends in a fixed suffix', () => { + // 'left' storing 'idp' vs a hypothetical name 'left:idp' is already + // rejected by normalizeSessionName; with valid names, prefixed keys + // of distinct names are always distinct. + expect(storageKey('left', 'idp')).not.toBe(storageKey('right', 'idp')); + expect(storageKey('left', 'idp')).not.toBe(storageKey(undefined, 'idp')); + }); +}); + +describe('databaseName', () => { + it('keeps the default database for the default session - existing persisted sessions survive', () => { + expect(databaseName(undefined)).toBe('soidc'); + }); + + it('gives a named session its own database', () => { + expect(databaseName('left')).toBe('soidc:left'); + expect(databaseName('right')).toBe('soidc:right'); + }); +}); diff --git a/tests/web/RefreshWorker.test.ts b/tests/web/RefreshWorker.test.ts index 29341cc..d93f063 100644 --- a/tests/web/RefreshWorker.test.ts +++ b/tests/web/RefreshWorker.test.ts @@ -733,4 +733,70 @@ describe('Refresher', () => { }); }); + + describe('database-name guard (named sessions)', () => { + let guarded: Refresher; + + beforeEach(() => { + guarded = new Refresher(mockBroadcast, mockDb, 'soidc:left'); + }); + + it('refuses SCHEDULE from a page expecting a different database', async () => { + await guarded.handleSchedule({ ...mockTokenDetails, dbName: 'soidc' } as any); + + expect(mockBroadcast).toHaveBeenCalledWith({ + type: RefreshMessageTypes.ERROR_ON_REFRESH, + error: expect.stringContaining('storage mismatch'), + }); + expect(guarded.getTokenDetails()).toBeUndefined(); + expect(guarded.getTimersAreRunning()).toBe(false); + }); + + it('accepts SCHEDULE from a page expecting this database, and stores tokenDetails without the dbName field', async () => { + await guarded.handleSchedule({ ...mockTokenDetails, dbName: 'soidc:left' } as any); + + expect(guarded.getTokenDetails()).toEqual(mockTokenDetails); + expect(guarded.getTimersAreRunning()).toBe(true); + }); + + it('refuses REFRESH from a page expecting a different database, without touching the database', async () => { + await guarded.handleRefresh(mockPort, 'soidc'); + + expect(mockBroadcast).toHaveBeenCalledWith({ + type: RefreshMessageTypes.ERROR_ON_REFRESH, + error: expect.stringContaining('storage mismatch'), + }); + expect(RefreshGrant.renewTokens).not.toHaveBeenCalled(); + expect(mockPort.postMessage).not.toHaveBeenCalled(); + }); + + it('serves REFRESH from a page expecting this database', async () => { + await guarded.handleSchedule({ ...mockTokenDetails, dbName: 'soidc:left' } as any); + await guarded.handleRefresh(mockPort, 'soidc:left'); + + expect(mockPort.postMessage).toHaveBeenCalledWith({ + type: RefreshMessageTypes.TOKEN_DETAILS, + payload: { tokenDetails: mockTokenDetails }, + }); + }); + + it('tolerates messages without a dbName (pages from before the handshake)', async () => { + await guarded.handleSchedule(mockTokenDetails); + expect(guarded.getTimersAreRunning()).toBe(true); + + await guarded.handleRefresh(mockPort); + expect(mockPort.postMessage).toHaveBeenCalledWith({ + type: RefreshMessageTypes.TOKEN_DETAILS, + payload: { tokenDetails: mockTokenDetails }, + }); + }); + + it('a Refresher without a bound dbName never guards (existing single-session behavior)', async () => { + await refresher.handleSchedule({ ...mockTokenDetails, dbName: 'anything' } as any); + expect(refresher.getTimersAreRunning()).toBe(true); + expect(mockBroadcast).not.toHaveBeenCalledWith(expect.objectContaining({ + error: expect.stringContaining('storage mismatch'), + })); + }); + }); }); \ No newline at end of file diff --git a/tests/web/Session.test.ts b/tests/web/Session.test.ts index 3c03256..4b9701d 100644 --- a/tests/web/Session.test.ts +++ b/tests/web/Session.test.ts @@ -137,7 +137,7 @@ describe('WebWorkerSession', () => { expect(mockSharedWorkerPort.postMessage).toHaveBeenCalledWith({ type: RefreshMessageTypes.SCHEDULE, - payload: mockTokenDetails, + payload: { ...mockTokenDetails, dbName: 'soidc' }, }); }); @@ -190,6 +190,7 @@ describe('WebWorkerSession', () => { expect(mockSharedWorkerPort.postMessage).toHaveBeenCalledWith({ type: RefreshMessageTypes.REFRESH, + payload: { dbName: 'soidc' }, }); }); @@ -567,4 +568,58 @@ describe('WebWorkerSession', () => { }); }); + describe('named sessions', () => { + it('a named session gets its own database and its own named SharedWorker', () => { + createSession({ name: 'left' }); + + expect(SessionIDB).toHaveBeenLastCalledWith('soidc:left'); + expect(SharedWorker).toHaveBeenLastCalledWith( + expect.any(URL), + { type: 'module', name: 'left' } + ); + }); + + it('the default session keeps the default database and an unnamed worker - byte-identical', () => { + createSession(); + + expect(SessionIDB).toHaveBeenLastCalledWith('soidc'); + expect(SharedWorker).toHaveBeenLastCalledWith( + expect.any(URL), + { type: 'module' } + ); + }); + + it('an empty-string name is the default session, so it cannot fork the storage namespace', () => { + createSession({ name: '' }); + + expect(SessionIDB).toHaveBeenLastCalledWith('soidc'); + expect(SharedWorker).toHaveBeenLastCalledWith( + expect.any(URL), + { type: 'module' } + ); + }); + + it('an invalid name throws instead of guessing a namespace', () => { + expect(() => createSession({ name: 'not ok' })).toThrow(TypeError); + expect(() => createSession({ name: 'soidc:x' })).toThrow(TypeError); + }); + + it('SCHEDULE and REFRESH carry the named session\'s expected database name', async () => { + const named = createSession({ name: 'left' }); + jest.spyOn((named as any), 'getExpiresIn').mockReturnValue(3600); + + await named.handleRedirectFromLogin(); + expect(mockSharedWorkerPort.postMessage).toHaveBeenCalledWith({ + type: RefreshMessageTypes.SCHEDULE, + payload: { ...mockTokenDetails, dbName: 'soidc:left' }, + }); + + named.restore(); + expect(mockSharedWorkerPort.postMessage).toHaveBeenCalledWith({ + type: RefreshMessageTypes.REFRESH, + payload: { dbName: 'soidc:left' }, + }); + }); + }); + }); \ No newline at end of file