Problem
DPoPTokenProvider keeps its per-issuer session — including the DPoP-bound refresh token and the DPoP CryptoKey — in an in-memory Map (#sessions). This is correct and safe, but it means a page reload or a return visit loses the session: the provider must re-run the authorization-code flow, which (with the silent prompt=none attempt) briefly opens a popup/window. For a returning user this is a visible, avoidable interruption.
The modern best-practice path (OAuth 2.0 for Browser-Based Apps BCP) is refresh-token rotation for SPAs rather than hidden-iframe silent renew — but that only helps across reloads if the refresh token (and its key) survive the reload.
Proposal (for discussion before any PR)
Add an optional, injectable SessionStore to DPoPTokenProvider:
interface SessionStore {
load(issuer: string): Promise<PersistedSession | undefined>
save(issuer: string, session: PersistedSession): Promise<void>
clear(issuer: string): Promise<void>
}
// PersistedSession = { issuer, webId, refreshToken, dpopKey: CryptoKeyPair, clientId?, expiresAt? }
// NB: the access token is deliberately NOT persisted.
- Default: no store → exactly today's in-memory behaviour (zero breaking change).
- Ship an
IndexedDbSessionStore as the batteries-included browser adapter.
- On every successful auth-code / refresh grant,
save() the rotated refresh token + key.
- Add
restore(issuer) that rebuilds the session via a refresh_token grant using the persisted key — a fetch, no window/iframe — and on invalid_grant calls clear() and reports "nothing restored". clear() on logout.
Why persisting the refresh token is acceptable here (the crux)
The DPoP CryptoKey is stored non-extractable (extractable: false). IndexedDB structured-clones a non-extractable CryptoKey faithfully — the raw private-key bytes never enter JS or touch disk, only an opaque signable handle. Because the refresh token is DPoP sender-constrained (RFC 9449), a token exfiltrated via XSS is useless without a proof signed by that non-extractable key. So persistence downgrades the exfiltration risk to an on-origin-only capability, which matches the DPoP refresh-token threat model. (The access token is never persisted regardless.)
Questions for the maintainers
- Is a persistence adapter in-scope for this library, or should it stay app-side (the consumer wires its own store)? I think a documented interface + an IndexedDB default earns its place because the non-extractable-key reasoning is subtle and easy to get wrong app-side.
SessionStore shape — is the PersistedSession field set right? Should clientId / dynamic-registration details be included for re-registration continuity?
- IndexedDB vs. a smaller
localStorage-token + IndexedDB-key split? (I went IndexedDB-only to keep the credential and its key together and origin-scoped.)
- Should
restore() be public API, or folded into the existing single-flight upgrade() path so consumers get it for free?
I have a working implementation + tests (round-trip preserves extractable:false, the restored key still signs a valid proof, access token never written, invalid_grant clears, no window opens on restore) running in a downstream app today, and am happy to turn it into a PR once the shape is agreed. Filing this first per the "discuss before PR" preference.
Problem
DPoPTokenProviderkeeps its per-issuer session — including the DPoP-bound refresh token and the DPoPCryptoKey— in an in-memoryMap(#sessions). This is correct and safe, but it means a page reload or a return visit loses the session: the provider must re-run the authorization-code flow, which (with the silentprompt=noneattempt) briefly opens a popup/window. For a returning user this is a visible, avoidable interruption.The modern best-practice path (OAuth 2.0 for Browser-Based Apps BCP) is refresh-token rotation for SPAs rather than hidden-iframe silent renew — but that only helps across reloads if the refresh token (and its key) survive the reload.
Proposal (for discussion before any PR)
Add an optional, injectable
SessionStoretoDPoPTokenProvider:IndexedDbSessionStoreas the batteries-included browser adapter.save()the rotated refresh token + key.restore(issuer)that rebuilds the session via arefresh_tokengrant using the persisted key — a fetch, no window/iframe — and oninvalid_grantcallsclear()and reports "nothing restored".clear()on logout.Why persisting the refresh token is acceptable here (the crux)
The DPoP
CryptoKeyis stored non-extractable (extractable: false). IndexedDB structured-clones a non-extractableCryptoKeyfaithfully — the raw private-key bytes never enter JS or touch disk, only an opaque signable handle. Because the refresh token is DPoP sender-constrained (RFC 9449), a token exfiltrated via XSS is useless without a proof signed by that non-extractable key. So persistence downgrades the exfiltration risk to an on-origin-only capability, which matches the DPoP refresh-token threat model. (The access token is never persisted regardless.)Questions for the maintainers
SessionStoreshape — is thePersistedSessionfield set right? ShouldclientId/ dynamic-registration details be included for re-registration continuity?localStorage-token + IndexedDB-key split? (I went IndexedDB-only to keep the credential and its key together and origin-scoped.)restore()be public API, or folded into the existing single-flightupgrade()path so consumers get it for free?I have a working implementation + tests (round-trip preserves
extractable:false, the restored key still signs a valid proof, access token never written,invalid_grantclears, no window opens on restore) running in a downstream app today, and am happy to turn it into a PR once the shape is agreed. Filing this first per the "discuss before PR" preference.