Skip to content

RFC (draft): consolidate OIDC/DPoP onto oauth4webapi v3 + dpop v2 (supersedes #4292)#4293

Draft
jeswr wants to merge 7 commits into
inrupt:mainfrom
jeswr:proposal/migrate-to-oauth4webapi
Draft

RFC (draft): consolidate OIDC/DPoP onto oauth4webapi v3 + dpop v2 (supersedes #4292)#4293
jeswr wants to merge 7 commits into
inrupt:mainfrom
jeswr:proposal/migrate-to-oauth4webapi

Conversation

@jeswr

@jeswr jeswr commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

⚠️ DRAFT / RFC — for discussion, not merge-ready. This is a large, security-critical refactor authored as a proposal. It was not built, typechecked, linted, or tested in the authoring environment (deps were not installed) — every line needs your CI (typecheck + unit + conformance/CTH + live IdPs) and careful review. Treat the code as a concrete, reviewable sketch of the target, not a finished change. Full details + a CI-validation checklist are in MIGRATION-oauth4webapi.md on the branch.

Motivation

The library currently runs two OAuth/DPoP stacks — packages/node on openid-client v5, packages/oidc-browser on oidc-client-ts + hand-rolled token exchange/refresh/DCR + hand-rolled DPoP proofs (jose). This RFC consolidates both onto oauth4webapi v3 + dpop v2 (panva — same author as jose), deleting a large amount of hand-rolled protocol code. The dpop package and oauth.DPoP() compute the RFC 9449 ath claim and handle use_dpop_nonce retries natively — so this supersedes #4292 (the minimal ath fix) by removing the code that had the bug.

Proven model: this mirrors the patterns in @solid/reactive-authentication, which already ships on oauth4webapi v3 + dpop v2.

What changed (both stacks now run oauth4webapi)

Area Before After
DPoP proof (core) hand-rolled jose SignJWT dpop.generateProof (ath native)
Browser grants (oidc-browser) hand-rolled token exchange / refresh / DCR authorizationCodeGrantRequest/refreshTokenGrantRequest/dynamicClientRegistrationRequest + oauth.DPoP()
Node stack (node) openid-client v5 + jose KeyObject DPoP bridge oauth4webapi grants + oauth.DPoP(); util/dpopInput.ts deleted

Public Session/handler APIs, EVENTS, error types, and the KeyPair storage format are preserved — dependents should compile unchanged. openid-client is removed; oidc-client-ts is partially retained (still drives the browser PKCE/redirect flow) and is called out as follow-up.

Review focus (flagged in the doc)

  • Client-credentials / conformance (highest): uses a non-URL-encoded ClientSecretBasic to match the legacy + ESS behaviour — must be confirmed by the conformance run.
  • DPoP key extractability vs persistence: the key stays JWK-persisted and is re-imported per request (refresh must reuse the same bound key). A non-extractable CryptoKeyPair end-to-end is noted as a follow-up.
  • http://localhost issuers: oauth4webapi v3 rejects http:// by default; allowInsecureRequests is re-enabled only for loopback issuers, matching legacy/dev/conformance.
  • Auth-response iss/state validation now runs via oauth.validateAuthResponse in the redirect handlers.

Phasing (commit-by-commit on the branch)

1 DPoP proof layer → 2 browser grants → 4 node stack → 3 cross-cutting polish → 5 dead-dep sweep. Each is a separate commit for reviewability.

Relationship to #4292 / #3184

#4292 is the minimal, mergeable-now ath fix (closes #3184). This RFC is the aggressive alternative that fixes the same bug structurally. Suggested path: land #4292 first to unblock affected apps quickly, then evaluate this RFC at your own pace.


Authored with AI assistance (Claude); to be validated by inrupt CI + maintainer review before any merge.

🤖 Generated with Claude Code

jeswr and others added 7 commits June 8, 2026 22:24
Proposal to replace hand-rolled jose-based OIDC/DPoP code with panva's
oauth4webapi (v3) and dpop (v2), modelled on @solid/reactive-authentication.

- MIGRATION-oauth4webapi.md: motivation, deletion/replacement map
  (~600-700 LOC removable), target architecture, public-API impact,
  risks, 4-phase plan, open questions. Subsumes/supersedes PR inrupt#4292
  (RFC 9449 `ath`) by fixing it natively via dpop.

POC (Phase 1 slice):
- dpopUtils.createDpopHeader now delegates to dpop.generateProof; `ath`
  and htu normalisation handled by the library. Adds optional accessToken
  + nonce params (backwards-compatible).
- fetchFactory passes the access token so resource-request proofs carry
  `ath` (the inrupt#4292 behavioural fix).
- dpopUtils.spec: adds ath present/absent tests; relaxes JWK assertion.
- core/package.json: adds oauth4webapi ^3 + dpop ^2 (not installed; CI
  resolves the lockfile).

Proposal only - nothing submitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dpop v2

Replace the hand-rolled token exchange, refresh, and DCR in
packages/oidc-browser (@inrupt/oidc-client-ext) with oauth4webapi grant
helpers and the oauth.DPoP() handle, mirroring @solid/reactive-authentication.

- dpop/tokenExchange.ts: getTokens delegates to authorizationCodeGrantRequest +
  processAuthorizationCodeResponse with a DPoP handle (auto ath + nonce retry).
  Public exports/signatures (getTokens, CodeExchangeResult, TokenEndpointInput,
  validateTokenEndpointResponse) preserved.
- refresh/refreshGrant.ts: refresh delegates to refreshTokenGrantRequest +
  processRefreshTokenResponse, reusing the SAME bound DPoP key.
- dcr/clientRegistrar.ts: registerClient delegates to
  dynamicClientRegistrationRequest + processDynamicClientRegistrationResponse;
  inrupt error messages preserved over ResponseBodyError.
- oauth/oauthAdapter.ts (new): IIssuerConfig->AuthorizationServer,
  IClient->Client, ClientAuth selection, JWK KeyPair->DPoP handle bridge, and
  oauth4webapi error -> OidcProviderError/InvalidResponseError mapping.
- package.json: add oauth4webapi ^3 + dpop ^2 (NOT installed; CI resolves lockfile).
- specs rewritten to mock the oauth4webapi boundary.

NOT built or tested (deps not installed per constraints) - requires CI
validation. See MIGRATION-oauth4webapi.md "Phase 2 - implementation notes" for
bridges/stubs and CI-validate flags. oidc-client-ts/uuid/openid-client removal
deferred to Phase 3/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace openid-client v5 across packages/node with oauth4webapi:
- discovery: Issuer.discover -> oauth.discoveryRequest/processDiscoveryResponse
- auth-code login: build the authorization URL manually + validateAuthResponse ->
  authorizationCodeGrantRequest/processAuthorizationCodeResponse (oauth.DPoP handle)
- client-credentials (conformance-critical): clientCredentialsGrantRequest/
  processClientCredentialsResponse
- refresh: refreshTokenGrantRequest/processRefreshTokenResponse, reusing the bound key
- DCR: dynamicClientRegistrationRequest/processDynamicClientRegistrationResponse

Add a node oauth seam (oauth/oauthAdapter.ts) mirroring the Phase 2 browser adapter
(ClientAuth selection, DPoP handles, CryptoKeyPair<->KeyPair, error mapping). Delete
src/util/dpopInput.ts (the openid-client<->jose KeyObject DPoP bridge). Drop openid-client
from package.json; add oauth4webapi ^3 + dpop ^2; keep jose + uuid (still used elsewhere).

Public Session/handler API, EVENTS, error types and the KeyPair storage format are
preserved. NOT built or tested (deps not installed) -- requires CI validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace jest.mock('openid-client') with jest.mock('oauth4webapi') across the node
specs, mocking the grant/discovery/DCR helpers (Phase 2 style). openid-client
call-shape assertions rewritten against oauth4webapi positional args; token_type
mocks lowercased. The issuer-metadata mock now uses oauth4webapi's AuthorizationServer
shape + configFromAuthorizationServer; ClientRegistrar mock uses oauth4webapi Client.

NOT executed (deps not installed) -- all spec changes are (CI-validate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the openid-client->oauth4webapi call mapping, files/LOC migrated, the
dpopInput.ts deletion, package.json changes, every (CI-validate) point (esp.
client-credentials/conformance + the extractable DPoP-key storage caveat), the
preserved public API, and what remains for Phase 3/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ost + TODO consistency)

Resolves the cross-cutting risks the per-package phases flagged, with no change
to any public/Session/handler API, EVENTS, error type, or storage format.

Auth-response validation placement (Phase-2 risk inrupt#1 / bridge inrupt#2):
- oidc-browser getTokens: TokenEndpointInput gains optional authResponseUrl +
  expectedState; when present, runs oauth.validateAuthResponse and uses its
  branded result directly (removing the previous branded *cast*). Legacy
  code-only path preserved when they are absent (back-compatible).
- browser AuthCodeRedirectHandler threads the incoming redirect URL +
  oauthState into getTokens. Node handler already did validateAuthResponse
  (Phase 4) — browser now matches.

Discovery/PKCE consistency + allowInsecureRequests:
- New isHttpLocalhost / allowInsecureForIssuer helpers in BOTH oauthAdapter
  seams. oauth4webapi v3 rejects http:// by default; the legacy stack
  implicitly allowed http://localhost (local dev / conformance). The flag is
  now spread into every oauth4webapi call (discovery, auth-code, refresh,
  client-credentials, DCR) in both packages, scoped to loopback origins only —
  real IdPs keep HTTPS enforcement.

id-token claims parity: confirmed getWebidFromTokenPayload (webid/azp/sub/iss)
is preserved on auth-code + refresh in both packages; expectNoNonce kept where
no nonce is threaded. No code change required.

DPoP key TODOs: persist-CryptoKeyPair notes made consistent across core +
both adapters; refresh reuses the same bound key via the JWK importJWK bridge.

NOT built or tested (deps not installed); requires CI validation. See
MIGRATION-oauth4webapi.md §11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removed now-unused direct dependencies, each only where there are zero
remaining import/require/type references in that package:

- core: removed `oauth4webapi` (core imports `dpop` directly but never
  oauth4webapi — only comment strings remain).
- oidc-browser: removed `dpop` (transitive via oauth4webapi; no direct import)
  and `uuid` + `@types/uuid` (zero references anywhere).
- node: removed `dpop` (transitive via oauth4webapi; no direct import).

Resolves the dpop direct-vs-transitive question: oauth4webapi^3 and dpop^2 are
now declared in exactly the packages that import them (dpop only in core;
oauth4webapi only in node + oidc-browser).

Deliberately KEPT: `oidc-client-ts` (oidc-browser — still drives browser
PKCE/redirect via OidcClient + cleanup.ts + re-exports consumed by
packages/browser); `jose` (real runtime use across core/node/oidc-browser;
browser only in specs but left as-is); `uuid` (node/core — `v4` still used).
`openid-client` was already fully removed in Phase 4 (no imports remain).

No source files newly orphaned: `dpopInput.ts` already deleted (Phase 4);
`validateTokenEndpointResponse` + has* guards retained intentionally and still
referenced/tested.

Lockfile NOT edited (deps not installable): CI must regenerate
package-lock.json + `npm dedupe`. NOT built/tested/submitted. See
MIGRATION-oauth4webapi.md §12 + the Validation checklist for CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Required ath claim is missing from DPoP header

1 participant