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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
with:
node-version-file: .nvmrc
- run: npm i
- run: npx tsc
- run: npm test

summary:
if: always()
Expand Down
102 changes: 92 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>

// A function that provides an Authorization Server URI based on the original request
let getIssuer: (request: Request) => Promise<URL>
```

### 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.

Expand All @@ -35,22 +32,29 @@ If the DOM contains
<idp-picker></idp-picker>
```

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])
```

Expand All @@ -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<T>` argument. Omitting it creates a separate `MemoryCache` for each provider. `Cache<T>` 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<DPoPTokenCacheEntry>("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<string>(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<T>` wraps a `Cache<ExpiringCacheEntry<T>>` 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,
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/Cache.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
get(key: string): Promise<T | undefined>
set(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
}
11 changes: 7 additions & 4 deletions src/CachingAuthorizationServerProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, oauth.AuthorizationServer> // TODO: Take cache from caller
readonly #cache: Cache<oauth.AuthorizationServer>
readonly #original: AuthorizationServerProvider

constructor(original: AuthorizationServerProvider) {
constructor(original: AuthorizationServerProvider, cache: Cache<oauth.AuthorizationServer> = new MemoryCache()) {
this.#cache = cache
this.#original = original
}

async getAuthorizationServer(request: Request): Promise<oauth.AuthorizationServer> {
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
}
}
11 changes: 7 additions & 4 deletions src/CachingClientProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, oauth.Client> // TODO: Take cache from caller
readonly #cache: Cache<oauth.Client>
readonly #original: ClientProvider

constructor(original: ClientProvider) {
constructor(original: ClientProvider, cache: Cache<oauth.Client> = new MemoryCache()) {
this.#cache = cache
this.#original = original
}

async getClient(as: oauth.AuthorizationServer, redirectUri: string, signal: AbortSignal): Promise<oauth.Client> {
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
}
}
13 changes: 8 additions & 5 deletions src/CachingIssuerProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, URL> // TODO: Take cache from caller
readonly #cache: Cache<string>
readonly #original: IssuerProvider

constructor(original: IssuerProvider) {
constructor(original: IssuerProvider, cache: Cache<string> = new MemoryCache()) {
this.#cache = cache
this.#original = original
}

async getIssuer(request: Request): Promise<URL> {
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
}
}
33 changes: 19 additions & 14 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand All @@ -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<string, CacheEntry>
// A cache must be isolated per application, client configuration and account.
readonly #cache: Cache<DPoPTokenCacheEntry>
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<DPoPTokenCacheEntry> = new MemoryCache()) {
this.#cache = cache
this.#codeProvider = codeProvider
this.#callbackUri = callbackUri
this.#asProvider = asProvider
Expand All @@ -50,9 +53,9 @@ export class DPoPTokenProvider implements TokenProvider {
return new Request(request, {headers})
}

private async getCachedToken(request: Request): Promise<CacheEntry> {
private async getCachedToken(request: Request): Promise<DPoPTokenCacheEntry> {
// 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) {
Expand All @@ -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<CacheEntry> {
private async obtainToken(request: Request): Promise<DPoPTokenCacheEntry> {
const authorizationServer = await this.#asProvider.getAuthorizationServer(request)

const clientRegistration = await this.#clientProvider.getClient(authorizationServer, this.#callbackUri, request.signal)
Expand Down Expand Up @@ -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<CacheEntry | undefined> {
private async refreshToken(cached: DPoPTokenCacheEntry, request: Request): Promise<DPoPTokenCacheEntry | undefined> {
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}

Expand All @@ -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")

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading