Skip to content

Optional SessionStore (persist the DPoP-bound refresh session) so reloads restore via a refresh grant, no silent-renew window #15

@jeswr

Description

@jeswr

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

  1. 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.
  2. SessionStore shape — is the PersistedSession field set right? Should clientId / dynamic-registration details be included for re-registration continuity?
  3. IndexedDB vs. a smaller localStorage-token + IndexedDB-key split? (I went IndexedDB-only to keep the credential and its key together and origin-scoped.)
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions