Skip to content

feat(auth): add createDcrProvider for RFC 7591 dynamic client registration#31

Merged
scottlovegrove merged 6 commits into
mainfrom
scottl/dcr-provider
May 21, 2026
Merged

feat(auth): add createDcrProvider for RFC 7591 dynamic client registration#31
scottlovegrove merged 6 commits into
mainfrom
scottl/dcr-provider

Conversation

@scottlovegrove
Copy link
Copy Markdown
Collaborator

@scottlovegrove scottlovegrove commented May 17, 2026

Summary

  • New createDcrProvider factory for the RFC 7591 Dynamic Client Registration flow. prepare() registers the client; authorize() runs the standard PKCE S256 dance; exchangeCode() redeems the code, authenticating the token request per the server-issued token_endpoint_auth_method (client_secret_basic default / _post / none / public-client fallback when DCR returned no secret).
  • DCR is driven by oauth4webapi — registration via dynamicClientRegistrationRequest / processDynamicClientRegistrationResponse, token exchange via authorizationCodeGrantRequest + ClientSecretBasic/ClientSecretPost/None. This replaces hand-rolled fetch + Basic-auth encoding with the library's RFC-conformant implementation (correct RFC 6749 §2.3.1 form-url-encoding of credentials, structured OAuth error parsing).
  • The lazy oauth4webapi loader and the expires_in → expiresAt helper are shared from _oauth.ts; createPkceProvider's refresh path reuses the same loader. oauth4webapi stays an optional peer dep — a missing install surfaces as AUTH_DCR_FAILED with an install hint.
  • The PKCE provider keeps its existing _oauth.ts-backed authorize / public-client token exchange (unchanged); only the DCR-specific Basic-auth helper (encodeBasicAuth) is removed, since oauth4webapi now owns that.
  • errorHints?: string[] on both factories, prepended to every CliError; server-returned error detail is appended after.
  • AUTH_DCR_FAILED code on the AuthErrorCode union.

Adopted RFC-conformant contract (via oauth4webapi)

Stricter than the previous lenient parser — DCR servers must comply:

  • Registration must return HTTP 201 (+ client_secret_expires_at when a client_secret is issued).
  • Token responses must carry token_type.
  • Endpoints must be HTTPS.

The Twist and Todoist registration endpoints have been updated to return 201 on successful creation, in line with this.

No external API change for existing createPkceProvider consumers.

Rebased onto main (feat(auth): silent refresh helper #39); merge resolved so PKCE refresh (oauth4webapi) and DCR coexist on the shared loader.

Validation

Verified end-to-end against twist.com via npm link into a downstream PR that migrates twist-cli's bespoke AuthProvider onto createDcrProvider. Twist PR follows once this releases.

Test plan

  • npm run check — clean
  • npm run type-check — clean
  • npm test — 435 pass
  • npm run build — clean
  • Live OAuth flow via linked twist-cli against twist.com — full DCR registration + PKCE + token exchange + persist

🤖 Generated with Claude Code

Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces the createDcrProvider for RFC 7591 dynamic client registration and cleanly refactors shared OAuth logic into a reusable helper module. The implementation successfully sets up the new dynamic flow while keeping the API surface consistent and significantly reducing downstream code size. There are a few remaining refinements to ensure protocol correctness—such as using the server-provided token_endpoint_auth_method, URL-encoding Basic Auth credentials, and caching client credentials to prevent redundant registrations—along with some opportunities to generalize type names, further deduplicate fetch and authorization logic, and expand test coverage for error hints and fallback behaviors.

Share FeedbackReview Logs

Comment thread src/auth/providers/_oauth.ts Outdated
Comment thread src/auth/providers/dcr.ts Outdated
Comment thread src/auth/providers/dcr.ts Outdated
Comment thread src/auth/providers/dcr.ts
Comment thread src/auth/providers/_oauth.ts Outdated
Comment thread src/auth/providers/dcr.ts
Comment thread src/auth/providers/dcr.test.ts
Comment thread src/auth/providers/_oauth.test.ts Outdated
scottlovegrove and others added 4 commits May 21, 2026 12:06
…ation

Ships the missing OAuth provider factory for flows that mint a per-install
`client_id` / `client_secret` via [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591).
`prepare()` POSTs the client metadata, threads the issued credentials
through the handshake, and `exchangeCode()` authenticates the token POST
per `tokenEndpointAuthMethod` (`client_secret_basic` default, `_post`, or
`none` for public-client). Mirrors `createPkceProvider`'s ergonomics —
caller supplies `validate`; same `PkceLazyString` resolvers for URLs.

Also:

- Extract a private `_oauth.ts` with `postTokenEndpoint`, `buildPkceAuthorizeUrl`,
  `buildAuthError`, `resolve`, `safeReadText`. `pkce.ts` is now thin (≈115
  LOC). The helper is grant-agnostic so a future refresh-token feature
  reuses it unchanged — `body: new URLSearchParams({grant_type: 'refresh_token', …})`.
- New `errorHints?: string[]` option on both factories. Prepended to every
  `CliError` they throw; server-returned body text (on non-2xx) is appended
  after so the actionable hint stays at the top.
- New `AUTH_DCR_FAILED` error code on the auth-error union.

No external API change for existing `createPkceProvider` consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop tests that duplicate behaviour already exercised elsewhere or that
test obvious helpers in isolation:

- `_oauth.test.ts`: remove standalone describes for `resolve`,
  `safeReadText`, and `buildPkceAuthorizeUrl` — already covered by the
  provider integration tests. Consolidate the four token-endpoint error
  cases into a single parametrised case and merge the duplicate
  errorHints assertion into the non-2xx test.
- `dcr.test.ts`: collapse the three DCR error tests (missing client_id +
  non-JSON kept as a single parametrised case; non-2xx absorbed the
  errorHints assertion) and drop the standalone "token endpoint non-2xx"
  test that overlaps with `_oauth.test.ts`.

Net –231 LOC across the two test files. 374 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…code Basic, dedupe fetch+parse

- DCR now persists the registration response's `token_endpoint_auth_method`
  on the handshake and lets it override the configured one at
  `exchangeCode()`, per RFC 7591 §3.2.1. Validate the value against the
  known set so an unknown method silently falls back to the configured one.
- URL-encode `client_id` and `client_secret` before joining with a colon
  for HTTP Basic auth per RFC 6749 §2.3.1. A literal colon (or any reserved
  character) in either value would otherwise corrupt the credential.
- Extract `postAndParseJson` in `_oauth.ts`. Both `postTokenEndpoint` and
  DCR `prepare()` now share the network / non-2xx / parse error wrapping
  while still validating their own success shape.
- Add `OAuthLazyString` as a grant-agnostic alias of `PkceLazyString`
  (non-breaking; existing name still exported).
- Export `DcrTokenEndpointAuthMethod` from the auth subpath so callers can
  refer to the union directly.
- Tests: cover the "no `client_secret` but `client_secret_post` requested →
  falls back to public-client POST" branch, the "server-issued
  `token_endpoint_auth_method` wins" branch, and the PKCE-level `errorHints`
  plumbing (both the token-endpoint path and the handshake-lost guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route DCR registration and token exchange through oauth4webapi
(dynamicClientRegistrationRequest / authorizationCodeGrantRequest with
ClientSecretBasic/Post/None) instead of hand-rolled fetch + Basic
encoding. Share the lazy oauth4webapi loader and expiry helper from
_oauth.ts; PKCE refresh reuses the same loader. oauth4webapi stays an
optional peer dep — a missing install surfaces as AUTH_DCR_FAILED with
an install hint.

This adopts oauth4webapi's RFC-conformant contract: registration must
return 201 (+ client_secret_expires_at when a secret is issued), token
responses must carry token_type, endpoints must be HTTPS, and Basic
credentials are form-url-encoded per RFC 6749 §2.3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottlovegrove
Copy link
Copy Markdown
Collaborator Author

@doistbot /review

Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces the new createDcrProvider factory for RFC 7591 dynamic client registration and integrates the oauth4webapi library to handle registration and token exchange. Relying on an established library for these flows improves the maintainability and correctness of our OAuth implementation by enforcing strict RFC compliance and removing hand-rolled encoding. A few minor adjustments are needed before merging, including failing fast on unsupported token endpoint auth methods, preserving caller-supplied error hints during dependency loading, restoring concurrent resolution for client credentials, and cleaning up some test mocks, types, and unused exports.

Share FeedbackReview Logs

Comment thread src/auth/providers/dcr.ts
Comment thread src/auth/providers/_oauth.ts Outdated
Comment thread src/auth/providers/dcr.ts Outdated
Comment thread src/auth/providers/pkce.ts Outdated
Comment thread src/auth/providers/dcr.test.ts Outdated
Comment thread src/auth/providers/_oauth.ts Outdated
scottlovegrove and others added 2 commits May 21, 2026 14:21
…, keep errorHints on load failure

- prepare() rejects with AUTH_DCR_FAILED when the registration server
  selects a token_endpoint_auth_method outside the supported set
  (basic/post/none) instead of silently falling back to the configured one.
- loadOauth4webapi accepts userHints, prepended on both failure branches so
  createDcrProvider({ errorHints }) survives a missing/broken peer dep.
- Restore concurrent Promise.all resolution of clientId + authorizeUrl in
  the PKCE authorize path.
- Use the grant-agnostic OAuthLazyString alias in _oauth.ts and dcr.ts.
- Drop export from internal-only _oauth.ts helper types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The underscore signalled "internal module", but cli-core re-exports
symbols individually (no `export *`), so the shared provider helpers
can't leak into the public surface by accident — the prefix is noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@scottlovegrove scottlovegrove merged commit dd34551 into main May 21, 2026
4 checks passed
@scottlovegrove scottlovegrove deleted the scottl/dcr-provider branch May 21, 2026 16:48
doist-release-bot Bot added a commit that referenced this pull request May 21, 2026
## [0.20.0](v0.19.0...v0.20.0) (2026-05-21)

### Features

* **auth:** add createDcrProvider for RFC 7591 dynamic client registration ([#31](#31)) ([dd34551](dd34551))
@doist-release-bot
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 0.20.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants