From 933edcbcfa9b7d5c5dcc42c546410dd898243fda Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:26:46 +0100 Subject: [PATCH] Add injectable provider caches and browser storage adapters --- .github/workflows/ci.yml | 2 +- README.md | 102 +++++++++++-- package.json | 4 +- src/Cache.ts | 11 ++ src/CachingAuthorizationServerProvider.ts | 11 +- src/CachingClientProvider.ts | 11 +- src/CachingIssuerProvider.ts | 13 +- src/DPoPTokenProvider.ts | 33 +++-- src/ExpiringCache.ts | 31 ++++ src/IndexedDbCache.ts | 75 ++++++++++ src/MemoryCache.ts | 22 +++ src/WebStorageCache.ts | 42 ++++++ src/createBrowserCaches.ts | 28 ++++ src/mod.ts | 6 + test/browser-cache.html | 58 ++++++++ test/cache.test.js | 167 ++++++++++++++++++++++ test/dpop-cache.test.js | 148 +++++++++++++++++++ test/providers.test.js | 45 ++++++ 18 files changed, 770 insertions(+), 39 deletions(-) create mode 100644 src/Cache.ts create mode 100644 src/ExpiringCache.ts create mode 100644 src/IndexedDbCache.ts create mode 100644 src/MemoryCache.ts create mode 100644 src/WebStorageCache.ts create mode 100644 src/createBrowserCaches.ts create mode 100644 test/browser-cache.html create mode 100644 test/cache.test.js create mode 100644 test/dpop-cache.test.js create mode 100644 test/providers.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 270c046..2020661 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: with: node-version-file: .nvmrc - run: npm i - - run: npx tsc + - run: npm test summary: if: always() diff --git a/README.md b/README.md index 98d3306..8fec92d 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,16 @@ A reactive authentication library supporting Solid OIDC. // The address of the protected resource to be requested let requestUri: string -// The address of a page that users return to after Authoentication Code flow +// The address of a page that users return to after Authorization Code flow let callbackUri: string -// A function that initiates Authorization Code flow and returns an Authorization Code -let getCode: (authorizationUri: URL, signal: AbortSignal) => Promise - // A function that provides an Authorization Server URI based on the original request let getIssuer: (request: Request) => Promise ``` ### Wiring up UI -`getCode` and `getIssuer` above can implemented arbitrarily. +The `CodeProvider` interface and `getIssuer` above can be implemented arbitrarily. But they can also be hooked up to UI elements provided by this library. @@ -35,22 +32,29 @@ If the DOM contains ``` -then the elements provide the required lambdas: +then the elements provide the code provider and issuer callback: ```js const codeUi = document.querySelector("authorization-code-flow") const issuerUi = document.querySelector("idp-picker") -getCode = codeUi.getCode.bind(codeUi) getIssuer = issuerUi.getIssuer.bind(issuerUi) ``` ### Setup ```js -import { DPoPTokenProvider, ReactiveFetchManager } from "@solid/reactive-authentication" - -const provider = new DPoPTokenProvider(callbackUri, getCode, getIssuer) +import { + DPoPTokenProvider, ReactiveFetchManager, CachingIssuerProvider, + XASProvider, CachingAuthorizationServerProvider, + DynamicRegistrationClientProvider, CachingClientProvider, +} from "@solid/reactive-authentication" + +// codeUi implements CodeProvider (including disposal of the popup). +const issuer = new CachingIssuerProvider({ getIssuer }) +const authorizationServer = new CachingAuthorizationServerProvider(new XASProvider(issuer)) +const client = new CachingClientProvider(new DynamicRegistrationClientProvider()) +const provider = new DPoPTokenProvider(callbackUri, codeUi, authorizationServer, client) const manager = new ReactiveFetchManager([provider]) ``` @@ -62,6 +66,80 @@ The `ReactiveFetchManager` provides a `fetch` function that can be used to reque const response = await manager.fetch(requestUri) ``` +### Configurable caches + +All existing caching providers accept an optional final `Cache` argument. Omitting it creates a separate `MemoryCache` for each provider. `Cache` has asynchronous `get`, `set`, `delete`, and `clear` methods; `get` returns `undefined` on a miss. Store resolved values, never promises or `undefined`. Mutations must finish before their promises resolve, and storage errors must reject. + +| Component | Cache value | Current key | +| --- | --- | --- | +| `CachingIssuerProvider` | `string` (issuer URL serialized as `href`) | Request URL | +| `CachingAuthorizationServerProvider` | `oauth.AuthorizationServer` | Request URL | +| `CachingClientProvider` | `oauth.Client` (may include secrets) | Issuer | +| `DPoPTokenProvider` | `DPoPTokenCacheEntry` (tokens, key pair, client, server, creation time) | Request URL | + +Issuer values are strings because `URL` is not a portable structured-clone storage type; callers still receive a `URL`. Request-to-storage resolution is separate future work in [#46](https://github.com/solid-contrib/reactive-authentication/issues/46). Sharing a cache across clients or accounts can authenticate as the wrong user: use a distinct cache namespace for each application, client configuration (including redirect URI), and account. A namespace is isolation by convention, not a security boundary against same-origin scripts. + +#### Browser preset + +`createBrowserCaches` explicitly opts into IndexedDB for issuer choices and discovery metadata with a one-hour write-time TTL. This is an application cache policy, not HTTP cache revalidation. Client registrations and credentials remain in memory. The lifetime is configurable in milliseconds: + +```js +import { createBrowserCaches } from "@solid/reactive-authentication" + +// Use an application-owned context identifier; never put tokens in namespaces. +const caches = createBrowserCaches("my-app/client-config-1/account-1", 60 * 60 * 1000) +const issuer = new CachingIssuerProvider({ getIssuer }, caches.issuer) +const authorizationServer = new CachingAuthorizationServerProvider(new XASProvider(issuer), caches.authorizationServer) +const client = new CachingClientProvider(new DynamicRegistrationClientProvider(), caches.client) +const provider = new DPoPTokenProvider(callbackUri, codeUi, authorizationServer, client, caches.token) +``` + +Choose storage according to the data: + +| Data | Recommended default | Optional persistence | +| --- | --- | --- | +| Issuer choices and public discovery metadata | Expiring IndexedDB | Web Storage with an explicit codec; memory for private browsing requirements | +| Client registrations, access tokens, refresh tokens | Memory | Explicit IndexedDB opt-in with application-managed lifetime and account isolation | +| DPoP private keys | Non-extractable Web Crypto keys in memory | IndexedDB structured clone, together with their bound tokens | +| DPoP proofs, authorization codes, PKCE verifiers, OAuth state/nonce | Per-request/flow only | Do not cache | + +The [IndexedDB API](https://www.w3.org/TR/IndexedDB/) stores structured-cloneable objects, including [Web Crypto keys](https://www.w3.org/TR/webcrypto-2/). The [Credential Management API](https://www.w3.org/TR/credential-management-1/) does not provide a generic OAuth token store. `localStorage` and `sessionStorage` store strings, cannot preserve a non-extractable key, and are accessible to same-origin scripts; `sessionStorage` is also unavailable in workers. Cache Storage is designed for HTTP request/response pairs rather than these typed credential records. + +#### Explicit credential persistence + +```ts +import { IndexedDbCache, type DPoPTokenCacheEntry } from "@solid/reactive-authentication" + +const tokens = new IndexedDbCache("my-app/client-config-1/account-1/tokens-v1") +const provider = new DPoPTokenProvider(callbackUri, codeUi, authorizationServer, client, tokens) +``` + +This persists the **whole credential record**, including access/refresh tokens and any client secret, atomically with its non-extractable key pair. It is not a refresh-only session store. On reload an unexpired access token is reused; expired tokens follow the existing refresh flow. A new DPoP proof is signed for every request. Do not use JSON serialization or export private keys to persist this record. Persisted token responses also do not retain oauth4webapi's in-memory validation associations; they are not a substitute for revalidating identity claims. + +Token reads, refreshes, and committed writes remain inside the existing request-URL Web Lock. Before a refresh grant, the old record is removed so a crash or failed replacement write cannot leave a consumed rotating refresh token for another tab to retry. A failed grant/write may therefore require authorization again. Only share this storage among cooperating providers in the same browser storage/lock partition; it is not a distributed refresh lock for server processes. + +Non-extractable keys prevent private-key export, but malicious same-origin JavaScript can still use a stored key to sign requests. Persistence increases exposure and does not promise hardware-backed storage or encryption at rest; see [OAuth 2.0 for Browser-Based Applications](https://www.rfc-editor.org/rfc/rfc10017.html). An application's consent, retention and logout policy must account for that. + +#### Other adapters and cache management + +`WebStorageCache` accepts a `Storage`, namespace, and codec. The codec must encode, decode, and validate its value type; use this only for non-secret data. For example, an issuer cache with tab-session lifetime: + +```ts +import { WebStorageCache } from "@solid/reactive-authentication" + +const issuerCache = new WebStorageCache(sessionStorage, "my-app/account-1/issuers-v1", { + encode: value => new URL(value).href, + decode: value => new URL(value).href, +}) +const issuer = new CachingIssuerProvider({ getIssuer }, issuerCache) +``` + +`ExpiringCache` wraps a `Cache>` with an absolute TTL. Expired entries are misses; reads never extend their lifetime or delete a concurrently replaced value. Expired records remain stored until overwritten, explicitly deleted, or cleared. Do not use access-token expiry as the TTL for the entire credential record: its refresh token may still be usable. + +Retain cache references to `delete(key)` or `clear()` them. Persistent adapters clear only their own namespace. Before clearing authentication state, stop and await in-flight upgrades and coordinate other tabs; clearing is not a cancellation fence, token revocation, or IdP logout. On account changes, also invalidate the issuer selection and client configuration as appropriate. Full logout semantics are tracked separately in #23. + +Storage can be denied, evicted, or run out of quota. Empty/evicted storage yields a miss; blocked database opens, failed transactions, codec errors, and quota/security errors reject. There is no silent memory fallback, which could split rotating-token state between tabs. Applications that cannot use persistence can select `MemoryCache` explicitly. Cache modules do not read browser globals at import time; browser APIs are accessed only when constructing/using their adapters. Use versioned namespaces when changing persisted value schemas and validate data in custom adapters where required. + ## Run the demo To compile, @@ -77,6 +155,10 @@ npx http-server then navigate to [localhost:8080](http://localhost:8080) (or wherever it was served). +## Testing + +Run `npm test` for the TypeScript build and Node test suite. IndexedDB unit tests use the dev-only `fake-indexeddb` implementation. For a real-browser smoke test, build, serve the repository over localhost, and open `test/browser-cache.html`. It reloads itself and checks persisted non-extractable key signing, both Web Storage APIs, the browser preset, and namespace isolation; the page reports `PASS` or `FAIL`. + ## Requirements ### Node.js diff --git a/package.json b/package.json index c35b054..3a5e459 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "url": "git+https://github.com/solid-contrib/reactive-authentication.git" }, "scripts": { - "build": "tsc" + "build": "tsc", + "test": "npm run build && node --test test/*.test.js" }, "license": "MIT", "dependencies": { @@ -38,6 +39,7 @@ "devDependencies": { "@rdfjs/types": "^2", "@types/n3": "^1", + "fake-indexeddb": "^6.2.5", "typedoc": "^0.28.18", "typedoc-plugin-mdn-links": "^5.1.1", "typescript": "^6" diff --git a/src/Cache.ts b/src/Cache.ts new file mode 100644 index 0000000..e084216 --- /dev/null +++ b/src/Cache.ts @@ -0,0 +1,11 @@ +/** + * Storage for resolved values. Undefined means a miss and must not be stored. + * Mutations resolve only after the storage operation completes; failures reject. + * Each instance/namespace must belong to one component and authentication context. + */ +export interface Cache { + get(key: string): Promise + set(key: string, value: T): Promise + delete(key: string): Promise + clear(): Promise +} diff --git a/src/CachingAuthorizationServerProvider.ts b/src/CachingAuthorizationServerProvider.ts index 267db11..bdc495c 100644 --- a/src/CachingAuthorizationServerProvider.ts +++ b/src/CachingAuthorizationServerProvider.ts @@ -1,22 +1,25 @@ +import type { Cache } from "./Cache.js" +import { MemoryCache } from "./MemoryCache.js" import type { AuthorizationServerProvider } from "./AuthorizationServerProvider.js" import type * as oauth from "oauth4webapi" export class CachingAuthorizationServerProvider implements AuthorizationServerProvider { - readonly #cache = new Map // TODO: Take cache from caller + readonly #cache: Cache readonly #original: AuthorizationServerProvider - constructor(original: AuthorizationServerProvider) { + constructor(original: AuthorizationServerProvider, cache: Cache = new MemoryCache()) { + this.#cache = cache this.#original = original } async getAuthorizationServer(request: Request): Promise { - const cached = this.#cache.get(request.url) + const cached = await this.#cache.get(request.url) if (cached !== undefined) { return cached } const fresh = await this.#original.getAuthorizationServer(request) - this.#cache.set(request.url, fresh) + await this.#cache.set(request.url, fresh) return fresh } } diff --git a/src/CachingClientProvider.ts b/src/CachingClientProvider.ts index ddda929..9bf1978 100644 --- a/src/CachingClientProvider.ts +++ b/src/CachingClientProvider.ts @@ -1,22 +1,25 @@ +import type { Cache } from "./Cache.js" +import { MemoryCache } from "./MemoryCache.js" import type { ClientProvider } from "./ClientProvider.js" import type * as oauth from "oauth4webapi" export class CachingClientProvider implements ClientProvider { - readonly #cache = new Map // TODO: Take cache from caller + readonly #cache: Cache readonly #original: ClientProvider - constructor(original: ClientProvider) { + constructor(original: ClientProvider, cache: Cache = new MemoryCache()) { + this.#cache = cache this.#original = original } async getClient(as: oauth.AuthorizationServer, redirectUri: string, signal: AbortSignal): Promise { - const cached = this.#cache.get(as.issuer) + const cached = await this.#cache.get(as.issuer) if (cached !== undefined) { return cached } const fresh = await this.#original.getClient(as, redirectUri, signal) - this.#cache.set(as.issuer, fresh) + await this.#cache.set(as.issuer, fresh) return fresh } } diff --git a/src/CachingIssuerProvider.ts b/src/CachingIssuerProvider.ts index 992342f..4924f14 100644 --- a/src/CachingIssuerProvider.ts +++ b/src/CachingIssuerProvider.ts @@ -1,21 +1,24 @@ +import type { Cache } from "./Cache.js" +import { MemoryCache } from "./MemoryCache.js" import { IssuerProvider } from "./IssuerProvider.js" export class CachingIssuerProvider implements IssuerProvider { - readonly #cache = new Map // TODO: Take cache from caller + readonly #cache: Cache readonly #original: IssuerProvider - constructor(original: IssuerProvider) { + constructor(original: IssuerProvider, cache: Cache = new MemoryCache()) { + this.#cache = cache this.#original = original } async getIssuer(request: Request): Promise { - const cached = this.#cache.get(request.url) + const cached = await this.#cache.get(request.url) if (cached !== undefined) { - return cached + return new URL(cached) } const fresh = await this.#original.getIssuer(request) - this.#cache.set(request.url, fresh) + await this.#cache.set(request.url, fresh.href) return fresh } } diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index d577955..2661512 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -1,3 +1,5 @@ +import type { Cache } from "./Cache.js" +import { MemoryCache } from "./MemoryCache.js" import * as oauth from "oauth4webapi" import * as DPoP from "dpop" import type { CodeProvider } from "./CodeProvider.js" @@ -6,7 +8,8 @@ import type { AuthorizationServerProvider } from "./AuthorizationServerProvider. import { ClientProvider } from "./ClientProvider.js" import { supportsOfflineAccess } from "./supportsOfflineAccess.js" -type CacheEntry = { +/** Sensitive, structured-cloneable credential record. Never JSON-serialize its keys. */ +export type DPoPTokenCacheEntry = { created: number, tokenResult: oauth.TokenEndpointResponse, dpopKey: CryptoKeyPair, @@ -18,13 +21,13 @@ export class DPoPTokenProvider implements TokenProvider { readonly #codeProvider: CodeProvider readonly #callbackUri: string - // TODO: Take cache from caller - // TODO: Once cache is externalized, document that it should not be shared between clients (which would lead to impersonation) - readonly #cache = new Map + // A cache must be isolated per application, client configuration and account. + readonly #cache: Cache readonly #asProvider: AuthorizationServerProvider readonly #clientProvider: ClientProvider - constructor(callbackUri: string, codeProvider: CodeProvider, asProvider: AuthorizationServerProvider, clientProvider: ClientProvider) { + constructor(callbackUri: string, codeProvider: CodeProvider, asProvider: AuthorizationServerProvider, clientProvider: ClientProvider, cache: Cache = new MemoryCache()) { + this.#cache = cache this.#codeProvider = codeProvider this.#callbackUri = callbackUri this.#asProvider = asProvider @@ -50,9 +53,9 @@ export class DPoPTokenProvider implements TokenProvider { return new Request(request, {headers}) } - private async getCachedToken(request: Request): Promise { + private async getCachedToken(request: Request): Promise { // TODO: More robust key via callback to support complex caching scenarios - const cached = this.#cache.get(request.url) + const cached = await this.#cache.get(request.url) // TODO: Support actively refreshing the token if (cached !== undefined) { @@ -62,18 +65,18 @@ export class DPoPTokenProvider implements TokenProvider { const refreshed = await this.refreshToken(cached, request) if (refreshed !== undefined) { - this.#cache.set(request.url, refreshed) + await this.#cache.set(request.url, refreshed) return refreshed } } const fresh = await this.obtainToken(request) - this.#cache.set(request.url, fresh) + await this.#cache.set(request.url, fresh) return fresh } - private async obtainToken(request: Request): Promise { + private async obtainToken(request: Request): Promise { const authorizationServer = await this.#asProvider.getAuthorizationServer(request) const clientRegistration = await this.#clientProvider.getClient(authorizationServer, this.#callbackUri, request.signal) @@ -142,11 +145,15 @@ export class DPoPTokenProvider implements TokenProvider { return {created: Date.now(), tokenResult, dpopKey, client: clientRegistration, authorizationServer} } - private async refreshToken(cached: CacheEntry, request: Request): Promise { + private async refreshToken(cached: DPoPTokenCacheEntry, request: Request): Promise { if (cached.tokenResult.refresh_token === undefined) { return undefined } + // Remove before consuming a potentially rotating token. A failed grant/write + // must not leave a consumed refresh token available to another tab. + await this.#cache.delete(request.url) + const dpop = oauth.DPoP({}, cached.dpopKey) const options = {DPoP: dpop} @@ -155,8 +162,6 @@ export class DPoPTokenProvider implements TokenProvider { const tokenResponse = await oauth.refreshTokenGrantRequest(cached.authorizationServer, cached.client, this.getClientAuth(cached.authorizationServer.issuer, cached.client), cached.tokenResult.refresh_token, options) tokenResult = await oauth.processRefreshTokenResponse(cached.authorizationServer, cached.client, tokenResponse) } catch (e) { - this.#cache.delete(request.url) - if (e instanceof oauth.ResponseBodyError && e.error === "invalid_grant") { console.debug("Access token could not be refreshed") @@ -237,7 +242,7 @@ function clientSecretBasicFor(issuer: string): (clientSecret: string) => oauth.C return oauth.ClientSecretBasic } -function isExpired(tokenData: CacheEntry) { +function isExpired(tokenData: DPoPTokenCacheEntry) { // TODO: Add some headroom (expire a bit before limit) // TODO: What to do when `expires_in` is Missing? (optional in https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2) return Date.now() - tokenData.created > tokenData.tokenResult.expires_in! * 1_000; diff --git a/src/ExpiringCache.ts b/src/ExpiringCache.ts new file mode 100644 index 0000000..47b51ec --- /dev/null +++ b/src/ExpiringCache.ts @@ -0,0 +1,31 @@ +import type { Cache } from "./Cache.js" + +export interface ExpiringCacheEntry { + value: T + expiresAt: number +} + +/** Absolute write-time TTL, preserved across reloads. Reads do not extend it. */ +export class ExpiringCache implements Cache { + constructor(private readonly cache: Cache>, private readonly maxAgeMs: number) { + if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) throw new TypeError("Cache maxAgeMs must be positive and finite") + } + + async get(key: string): Promise { + const entry = await this.cache.get(key) + // Do not delete after a read: another tab may have just replaced the entry. + return entry !== undefined && entry.expiresAt > Date.now() ? entry.value : undefined + } + + async set(key: string, value: T): Promise { + await this.cache.set(key, {value, expiresAt: Date.now() + this.maxAgeMs}) + } + + async delete(key: string): Promise { + await this.cache.delete(key) + } + + async clear(): Promise { + await this.cache.clear() + } +} diff --git a/src/IndexedDbCache.ts b/src/IndexedDbCache.ts new file mode 100644 index 0000000..06afc08 --- /dev/null +++ b/src/IndexedDbCache.ts @@ -0,0 +1,75 @@ +import type { Cache } from "./Cache.js" + +/** + * Structured-clone storage, including non-extractable CryptoKeys. Use a unique, + * versioned namespace per component, application, client configuration and account. + * Importing this module does not access browser globals. No silent memory fallback. + */ +export class IndexedDbCache implements Cache { + readonly #name: string + + constructor(namespace: string, private readonly factory: IDBFactory = globalThis.indexedDB) { + if (namespace.length === 0) throw new TypeError("A cache namespace is required") + this.#name = `reactive-authentication:${namespace}` + } + + async get(key: string): Promise { + return this.run("readonly", store => store.get(key)) + } + + async set(key: string, value: T): Promise { + await this.run("readwrite", store => store.put(value, key)) + } + + async delete(key: string): Promise { + await this.run("readwrite", store => store.delete(key)) + } + + async clear(): Promise { + await this.run("readwrite", store => store.clear()) + } + + private async open(): Promise { + if (this.factory === undefined) throw new Error("IndexedDB is unavailable") + return new Promise((resolve, reject) => { + const request = this.factory.open(this.#name, 1) + let blocked = false + request.onupgradeneeded = () => request.result.createObjectStore("entries") + request.onerror = () => reject(request.error) + request.onblocked = () => { + blocked = true + reject(new Error("Opening the cache database was blocked")) + } + request.onsuccess = () => { + if (blocked) { + request.result.close() + } else { + request.result.onversionchange = () => request.result.close() + resolve(request.result) + } + } + }) + } + + private async run(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest): Promise { + const database = await this.open() + try { + return await new Promise((resolve, reject) => { + const transaction = database.transaction("entries", mode) + // Request success alone does not guarantee the write was committed. + transaction.onabort = () => reject(transaction.error ?? new Error("Cache transaction aborted")) + let request: IDBRequest + try { + request = operation(transaction.objectStore("entries")) + } catch (error) { + transaction.abort() + reject(error) + return + } + transaction.oncomplete = () => resolve(request.result) + }) + } finally { + database.close() + } + } +} diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts new file mode 100644 index 0000000..6c84f09 --- /dev/null +++ b/src/MemoryCache.ts @@ -0,0 +1,22 @@ +import type { Cache } from "./Cache.js" + +/** Per-instance storage. Values retain their identity and are not serialized. */ +export class MemoryCache implements Cache { + readonly #values = new Map() + + async get(key: string): Promise { + return this.#values.get(key) + } + + async set(key: string, value: T): Promise { + this.#values.set(key, value) + } + + async delete(key: string): Promise { + this.#values.delete(key) + } + + async clear(): Promise { + this.#values.clear() + } +} diff --git a/src/WebStorageCache.ts b/src/WebStorageCache.ts new file mode 100644 index 0000000..be5a440 --- /dev/null +++ b/src/WebStorageCache.ts @@ -0,0 +1,42 @@ +import type { Cache } from "./Cache.js" + +/** Explicit encoding/validation for string-only storage; never use for CryptoKeys. */ +export interface CacheCodec { + encode(value: T): string + decode(value: string): T +} + +/** + * An opt-in adapter for localStorage or sessionStorage and non-secret values. + * Storage/security/quota and decoding errors reject; clear affects only this namespace. + */ +export class WebStorageCache implements Cache { + readonly #prefix: string + + constructor(private readonly storage: Storage, namespace: string, private readonly codec: CacheCodec) { + if (namespace.length === 0) throw new TypeError("A cache namespace is required") + this.#prefix = `reactive-authentication:${JSON.stringify(namespace)}:` + } + + async get(key: string): Promise { + const value = this.storage.getItem(this.#prefix + key) + return value === null ? undefined : this.codec.decode(value) + } + + async set(key: string, value: T): Promise { + this.storage.setItem(this.#prefix + key, this.codec.encode(value)) + } + + async delete(key: string): Promise { + this.storage.removeItem(this.#prefix + key) + } + + async clear(): Promise { + const keys: string[] = [] + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i) + if (key?.startsWith(this.#prefix)) keys.push(key) + } + for (const key of keys) this.storage.removeItem(key) + } +} diff --git a/src/createBrowserCaches.ts b/src/createBrowserCaches.ts new file mode 100644 index 0000000..27baacf --- /dev/null +++ b/src/createBrowserCaches.ts @@ -0,0 +1,28 @@ +import type * as oauth from "oauth4webapi" +import type { Cache } from "./Cache.js" +import type { DPoPTokenCacheEntry } from "./DPoPTokenProvider.js" +import { ExpiringCache } from "./ExpiringCache.js" +import { IndexedDbCache } from "./IndexedDbCache.js" +import { MemoryCache } from "./MemoryCache.js" + +export interface ProviderCaches { + issuer: Cache + authorizationServer: Cache + client: Cache + token: Cache +} + +/** + * Explicit browser preset: public metadata in IndexedDB for one hour by default; + * client registrations and credentials in memory. Namespace must isolate app/account + * contexts. IndexedDB failures reject; unavailable IndexedDB is not silently ignored. + */ +export function createBrowserCaches(namespace: string, metadataMaxAgeMs = 60 * 60 * 1000): ProviderCaches { + if (namespace.length === 0) throw new TypeError("A cache namespace is required") + return { + issuer: new ExpiringCache(new IndexedDbCache(`${namespace}:v1:issuer`), metadataMaxAgeMs), + authorizationServer: new ExpiringCache(new IndexedDbCache(`${namespace}:v1:authorization-server`), metadataMaxAgeMs), + client: new MemoryCache(), + token: new MemoryCache(), + } +} diff --git a/src/mod.ts b/src/mod.ts index c704f1b..94ea121 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -23,3 +23,9 @@ export * from "./DynamicRegistrationClientProvider.js" export * from "./CachingClientProvider.js" export * from "./supportsOfflineAccess.js" export * from "./ClientIdClientProvider.js" +export * from "./Cache.js" +export * from "./MemoryCache.js" +export * from "./WebStorageCache.js" +export * from "./IndexedDbCache.js" +export * from "./ExpiringCache.js" +export * from "./createBrowserCaches.js" diff --git a/test/browser-cache.html b/test/browser-cache.html new file mode 100644 index 0000000..4038a85 --- /dev/null +++ b/test/browser-cache.html @@ -0,0 +1,58 @@ + + + +Browser cache smoke test + +
Running…
+ + + diff --git a/test/cache.test.js b/test/cache.test.js new file mode 100644 index 0000000..b0b6f14 --- /dev/null +++ b/test/cache.test.js @@ -0,0 +1,167 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { IDBFactory } from "fake-indexeddb" +import { MemoryCache } from "../dist/MemoryCache.js" +import { IndexedDbCache } from "../dist/IndexedDbCache.js" +import { WebStorageCache } from "../dist/WebStorageCache.js" +import { ExpiringCache } from "../dist/ExpiringCache.js" +import { createBrowserCaches } from "../dist/createBrowserCaches.js" + +class TestStorage { + values = new Map() + get length() { return this.values.size } + key(index) { return [...this.values.keys()][index] ?? null } + getItem(key) { return this.values.get(key) ?? null } + setItem(key, value) { this.values.set(key, value) } + removeItem(key) { this.values.delete(key) } +} +const codec = { encode: JSON.stringify, decode: JSON.parse } + +for (const [name, create] of [ + ["memory", () => new MemoryCache()], + ["IndexedDB", () => new IndexedDbCache("contract", new IDBFactory())], + ["Web Storage", () => new WebStorageCache(new TestStorage(), "contract", codec)], +]) { + test(`${name}: miss, overwrite, delete, clear, and falsy values`, async () => { + const cache = create() + assert.equal(await cache.get("missing"), undefined) + for (const value of [false, 0, "", null, { issuer: "https://idp.example" }]) { + await cache.set("key", value) + assert.deepEqual(await cache.get("key"), value) + } + await cache.delete("key") + await cache.delete("missing") + assert.equal(await cache.get("key"), undefined) + await cache.set("a", 1) + await cache.set("b", 2) + await cache.clear() + assert.equal(await cache.get("a"), undefined) + assert.equal(await cache.get("b"), undefined) + }) +} + +test("persistent adapters survive reconstruction and clear only their namespace", async () => { + const storage = new TestStorage() + storage.setItem("unrelated", "keep") + const factory = new IDBFactory() + for (const create of [ + name => new WebStorageCache(storage, name, codec), + name => new IndexedDbCache(name, factory), + ]) { + const first = create("app") + const second = create("app:other") + await first.set("key", "first") + await second.set("key", "second") + assert.equal(await create("app").get("key"), "first") + await first.clear() + assert.equal(await second.get("key"), "second") + } + assert.equal(storage.getItem("unrelated"), "keep") +}) + +test("IndexedDB preserves a non-extractable private key that still signs", async () => { + const factory = new IDBFactory() + const keys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]) + await new IndexedDbCache("keys", factory).set("key", keys) + const restored = await new IndexedDbCache("keys", factory).get("key") + assert.equal(restored.privateKey.extractable, false) + await assert.rejects(crypto.subtle.exportKey("jwk", restored.privateKey)) + const data = new TextEncoder().encode("proof") + const algorithm = { name: "ECDSA", hash: "SHA-256" } + const signature = await crypto.subtle.sign(algorithm, restored.privateKey, data) + assert.equal(await crypto.subtle.verify(algorithm, keys.publicKey, signature, data), true) +}) + +test("IndexedDB rejects uncloneable values without replacing existing entries", async () => { + const cache = new IndexedDbCache("clone-errors", new IDBFactory()) + await cache.set("key", "original") + await assert.rejects(cache.set("key", () => {}), { name: "DataCloneError" }) + assert.equal(await cache.get("key"), "original") +}) + +test("IndexedDB waits for commit and rejects a transaction aborted after request success", async () => { + const factory = new IDBFactory() + const open = factory.open.bind(factory) + factory.open = (...args) => { + const request = open(...args) + request.addEventListener("success", () => { + const database = request.result + const transaction = database.transaction.bind(database) + database.transaction = (...args) => { + const tx = transaction(...args) + if (args[1] === "readwrite") { + const objectStore = tx.objectStore.bind(tx) + tx.objectStore = name => { + const store = objectStore(name) + const put = store.put.bind(store) + store.put = (...args) => { + const write = put(...args) + write.addEventListener("success", () => tx.abort()) + return write + } + return store + } + } + return tx + } + }) + return request + } + const cache = new IndexedDbCache("abort", factory) + await assert.rejects(cache.set("key", "uncommitted"), /aborted/) + assert.equal(await cache.get("key"), undefined) +}) + +test("storage denial and malformed encodings are surfaced", async () => { + const denied = new DOMException("denied", "SecurityError") + const cache = new WebStorageCache({ getItem() { throw denied }, setItem() { throw denied } }, "denied", codec) + await assert.rejects(cache.get("a"), denied) + await assert.rejects(cache.set("a", 1), denied) + const storage = new TestStorage() + const corrupt = new WebStorageCache(storage, "corrupt", { encode: () => "{", decode: JSON.parse }) + await corrupt.set("a", 1) + await assert.rejects(corrupt.get("a"), SyntaxError) + await assert.rejects(new IndexedDbCache("denied", { open() { throw denied } }).get("a"), denied) +}) + +test("TTL survives reconstruction, expires at the boundary, and does not delete newer data", async t => { + t.mock.method(Date, "now", () => 1000) + const backing = new MemoryCache() + const cache = new ExpiringCache(backing, 100) + await cache.set("a", "old") + t.mock.method(Date, "now", () => 1099) + assert.equal(await new ExpiringCache(backing, 100).get("a"), "old") + t.mock.method(Date, "now", () => 1100) + assert.equal(await cache.get("a"), undefined) + assert.equal((await backing.get("a")).value, "old") + await cache.set("a", "new") + assert.equal(await cache.get("a"), "new") + await cache.delete("a") + assert.equal(await cache.get("a"), undefined) + await cache.set("b", "new") + await cache.clear() + assert.equal(await cache.get("b"), undefined) + for (const age of [0, -1, NaN, Infinity]) assert.throws(() => new ExpiringCache(backing, age), TypeError) +}) + +test("browser preset persists only metadata and expires it", async t => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "indexedDB") + Object.defineProperty(globalThis, "indexedDB", { configurable: true, value: new IDBFactory() }) + t.after(() => { + if (previous) Object.defineProperty(globalThis, "indexedDB", previous) + else delete globalThis.indexedDB + }) + t.mock.method(Date, "now", () => 1000) + const caches = createBrowserCaches("my-app/account", 100) + await caches.issuer.set("resource", "https://idp.example") + await caches.authorizationServer.set("resource", { issuer: "https://idp.example" }) + await caches.client.set("issuer", { client_id: "private-client" }) + await caches.token.set("resource", { secret: "memory only" }) + const restored = createBrowserCaches("my-app/account", 100) + assert.equal(await restored.issuer.get("resource"), "https://idp.example") + assert.deepEqual(await restored.authorizationServer.get("resource"), { issuer: "https://idp.example" }) + assert.equal(await restored.client.get("issuer"), undefined) + assert.equal(await restored.token.get("resource"), undefined) + t.mock.method(Date, "now", () => 1100) + assert.equal(await restored.authorizationServer.get("resource"), undefined) +}) diff --git a/test/dpop-cache.test.js b/test/dpop-cache.test.js new file mode 100644 index 0000000..5f5e9fd --- /dev/null +++ b/test/dpop-cache.test.js @@ -0,0 +1,148 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { IDBFactory } from "fake-indexeddb" +import { MemoryCache } from "../dist/MemoryCache.js" +import { IndexedDbCache } from "../dist/IndexedDbCache.js" +import { DPoPTokenProvider } from "../dist/DPoPTokenProvider.js" + +const url = "https://pod.example/resource" +const callback = "https://app.example/callback" +const authorizationServer = { + issuer: "https://idp.example", + authorization_endpoint: "https://idp.example/authorize", + token_endpoint: "https://idp.example/token", + code_challenge_methods_supported: ["S256"], +} +const client = { client_id: "app", redirect_uris: [callback], response_types: ["code"] } +const unreachable = { + async getCode() { throw new Error("unexpected authorization") }, + async getAuthorizationServer() { throw new Error("unexpected discovery") }, + async getClient() { throw new Error("unexpected registration") }, +} +const providerFor = cache => new DPoPTokenProvider(callback, unreachable, unreachable, unreachable, cache) +const claims = jwt => JSON.parse(Buffer.from(jwt.split(".")[1], "base64url")) + +async function entry(expired = false) { + return { + created: expired ? 0 : Date.now(), + tokenResult: { access_token: "access", token_type: "dpop", refresh_token: "refresh-1", expires_in: 3600 }, + dpopKey: await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]), + client, authorizationServer, + } +} + +test("restored IndexedDB credentials produce a fresh request-bound proof each time", async () => { + const factory = new IDBFactory() + await new IndexedDbCache("tokens", factory).set(url, await entry()) + const provider = providerFor(new IndexedDbCache("tokens", factory)) + const first = await provider.upgrade(new Request(url)) + const second = await provider.upgrade(new Request(url, { method: "POST" })) + assert.equal(first.headers.get("Authorization"), "DPoP access") + const firstProof = claims(first.headers.get("DPoP")) + const secondProof = claims(second.headers.get("DPoP")) + assert.equal(firstProof.htm, "GET") + assert.equal(secondProof.htm, "POST") + assert.equal(firstProof.htu, url) + assert.notEqual(firstProof.jti, secondProof.jti) + await assert.rejects(provider.upgrade(new Request("https://pod.example/other")), /unexpected discovery/) +}) + +test("two providers sharing persistent credentials rotate once and await durable writes", async t => { + const factory = new IDBFactory() + const cache = new IndexedDbCache("rotation", factory) + const original = await entry(true) + await cache.set(url, original) + let grants = 0 + t.mock.method(globalThis, "fetch", async (input, options) => { + assert.equal(String(input), authorizationServer.token_endpoint) + assert.equal(new URLSearchParams(options.body).get("refresh_token"), "refresh-1") + assert.equal(await cache.get(url), undefined, "consumed token must not remain durable") + grants++ + return Response.json({ access_token: "renewed", token_type: "DPoP", refresh_token: "refresh-2", expires_in: 3600 }) + }) + const results = await Promise.all([ + providerFor(cache).upgrade(new Request(url)), + providerFor(new IndexedDbCache("rotation", factory)).upgrade(new Request(url)), + ]) + assert.equal(grants, 1) + assert.ok(results.every(result => result.headers.get("Authorization") === "DPoP renewed")) + const restored = await new IndexedDbCache("rotation", factory).get(url) + assert.equal(restored.tokenResult.refresh_token, "refresh-2") + assert.deepEqual(await crypto.subtle.exportKey("jwk", restored.dpopKey.publicKey), await crypto.subtle.exportKey("jwk", original.dpopKey.publicKey)) +}) + +test("refresh without rotation retains the previous refresh token", async t => { + const cache = new MemoryCache() + await cache.set(url, await entry(true)) + t.mock.method(globalThis, "fetch", async () => Response.json({ access_token: "renewed", token_type: "DPoP", expires_in: 3600 })) + await providerFor(cache).upgrade(new Request(url)) + assert.equal((await cache.get(url)).tokenResult.refresh_token, "refresh-1") +}) + +test("failed durable rotation does not expose credentials or leave the consumed refresh token", async t => { + const cache = new MemoryCache() + await cache.set(url, await entry(true)) + t.mock.method(globalThis, "fetch", async () => Response.json({ access_token: "renewed", token_type: "DPoP", refresh_token: "refresh-2", expires_in: 3600 })) + t.mock.method(cache, "set", async () => { throw new Error("quota") }) + await assert.rejects(providerFor(cache).upgrade(new Request(url)), /quota/) + assert.equal(await cache.get(url), undefined) +}) + +test("failed invalidation prevents consumption of the refresh token", async t => { + const cache = new MemoryCache() + await cache.set(url, await entry(true)) + t.mock.method(cache, "delete", async () => { throw new Error("denied") }) + const fetch = t.mock.method(globalThis, "fetch", async () => { throw new Error("must not fetch") }) + await assert.rejects(providerFor(cache).upgrade(new Request(url)), /denied/) + assert.equal(fetch.mock.callCount(), 0) +}) + +test("invalid_grant evicts before reauthorization; other refresh errors propagate", async t => { + const cache = new MemoryCache() + let invalid = true + t.mock.method(globalThis, "fetch", async () => Response.json({ error: invalid ? "invalid_grant" : "temporarily_unavailable" }, { status: 400 })) + await cache.set(url, await entry(true)) + await assert.rejects(providerFor(cache).upgrade(new Request(url)), /unexpected discovery/) + assert.equal(await cache.get(url), undefined) + invalid = false + await cache.set(url, await entry(true)) + await assert.rejects(providerFor(cache).upgrade(new Request(url)), error => error.error === "temporarily_unavailable") + assert.equal(await cache.get(url), undefined) +}) + +test("fresh authorization caches only successful results and preserves constructor defaults", async t => { + const cache = new MemoryCache() + let cancel = true + let nonce + let codeCalls = 0 + const codeProvider = { async getCode(authorizationUrl) { + codeCalls++ + if (cancel) throw new Error("cancelled") + nonce = authorizationUrl.searchParams.get("nonce") + const response = new URL(callback) + response.searchParams.set("code", "code") + response.searchParams.set("state", authorizationUrl.searchParams.get("state")) + return { value: response.href, [Symbol.dispose]() {} } + } } + const signingKeys = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]) + t.mock.method(globalThis, "fetch", async () => { + const now = Math.floor(Date.now() / 1000) + const jwt = [ { alg: "ES256" }, { iss: authorizationServer.issuer, aud: client.client_id, sub: "user", nonce, iat: now, exp: now + 3600 } ].map(value => Buffer.from(JSON.stringify(value)).toString("base64url")).join(".") + const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, signingKeys.privateKey, new TextEncoder().encode(jwt)) + return Response.json({ access_token: "fresh", token_type: "DPoP", expires_in: 3600, id_token: `${jwt}.${Buffer.from(signature).toString("base64url")}` }) + }) + const asProvider = { async getAuthorizationServer() { return authorizationServer } } + const clientProvider = { async getClient() { return { ...client, id_token_signed_response_alg: "ES256" } } } + const provider = new DPoPTokenProvider(callback, codeProvider, asProvider, clientProvider, cache) + await assert.rejects(provider.upgrade(new Request(url)), /cancelled/) + assert.equal(await cache.get(url), undefined) + cancel = false + const results = await Promise.all([provider.upgrade(new Request(url)), provider.upgrade(new Request(url))]) + assert.ok(results.every(result => result.headers.get("Authorization") === "DPoP fresh")) + assert.equal(codeCalls, 2) + assert.equal((await cache.get(url)).tokenResult.access_token, "fresh") + const defaults = new DPoPTokenProvider(callback, codeProvider, asProvider, clientProvider) + await defaults.upgrade(new Request(url)) + await defaults.upgrade(new Request(url)) + assert.equal(codeCalls, 3) +}) diff --git a/test/providers.test.js b/test/providers.test.js new file mode 100644 index 0000000..0895d91 --- /dev/null +++ b/test/providers.test.js @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { MemoryCache } from "../dist/MemoryCache.js" +import { CachingIssuerProvider } from "../dist/CachingIssuerProvider.js" +import { CachingAuthorizationServerProvider } from "../dist/CachingAuthorizationServerProvider.js" +import { CachingClientProvider } from "../dist/CachingClientProvider.js" + +for (const [Provider, method, args, result, stored, key] of [ + [CachingIssuerProvider, "getIssuer", [new Request("https://pod.example/a")], new URL("https://idp.example"), "https://idp.example/", "https://pod.example/a"], + [CachingAuthorizationServerProvider, "getAuthorizationServer", [new Request("https://pod.example/a")], { issuer: "https://idp.example" }, { issuer: "https://idp.example" }, "https://pod.example/a"], + [CachingClientProvider, "getClient", [{ issuer: "https://idp.example" }, "https://app.example/cb", new AbortController().signal], { client_id: "app" }, { client_id: "app" }, "https://idp.example"], +]) { + test(`${Provider.name}: injected async cache, reconstruction, invalidation, failure retry`, async () => { + const cache = new MemoryCache() + let calls = 0 + const original = { async [method]() { calls++; return result } } + const provider = new Provider(original, cache) + assert.deepEqual(await provider[method](...args), result) + assert.deepEqual(await cache.get(key), stored) + assert.deepEqual(await new Provider(original, cache)[method](...args), result) + assert.equal(calls, 1) + await cache.delete(key) + await provider[method](...args) + assert.equal(calls, 2) + await cache.clear() + let failed = true + const retry = new Provider({ async [method]() { if (failed) throw new Error("cancelled"); return result } }, cache) + await assert.rejects(retry[method](...args), /cancelled/) + assert.equal(await cache.get(key), undefined) + failed = false + assert.deepEqual(await retry[method](...args), result) + }) + + test(`${Provider.name}: default memory cache and awaited write failure`, async () => { + let calls = 0 + const original = { async [method]() { calls++; return result } } + const provider = new Provider(original) + await provider[method](...args) + await provider[method](...args) + assert.equal(calls, 1) + const cache = new MemoryCache() + cache.set = async () => { await Promise.resolve(); throw new Error("quota") } + await assert.rejects(new Provider(original, cache)[method](...args), /quota/) + }) +}