Skip to content

feat(auth)!: node-issued OTP token exchange with magic-link authentication#40

Merged
antfu merged 7 commits into
devframes:mainfrom
antfubot:feat/auth-otp-token-exchange
Jun 19, 2026
Merged

feat(auth)!: node-issued OTP token exchange with magic-link authentication#40
antfu merged 7 commits into
devframes:mainfrom
antfubot:feat/auth-otp-token-exchange

Conversation

@antfubot

@antfubot antfubot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What & why

Reworks dev-mode auth to balance UX, security, and dependency footprint, and removes the vendored human-id dependency.

  1. Crypto-secure tokens — replace the vendored human-id word generator with a runtime-agnostic devframe/utils/crypto-token util (randomToken/randomDigits/timingSafeEqual, WebCrypto CSPRNG). Bearer and remote-dock tokens are now 128-bit CSPRNG values instead of Math.random word-pairs.
  2. 6-digit one-time code — the human-typed code has a TTL, constant-time compare, and a failed-attempt cap.
  3. Node-issued token exchange — pivot from "the browser mints a token, the node approves it" to "the node issues the token via a code exchange".
  4. Magic-link authentication (devframe_otp) — optional URL bootstrap with reusable client utilities so integrations can drive their own flow.
  5. Security docs — document the trust model, the auth methods + flow, and secure-by-default practices.

Authentication flow

A fresh client connects unauthenticated; the dev server shows a 6-digit code (or a magic link); the browser calls requestTrustWithCode(code)devframe:auth:exchange; the node verifies, mints a bearer token, stores it trusted, and returns it. The client persists the token and broadcasts it to sibling tabs. On reconnect it presents the stored token (devframe:anonymous:authverifyAuthToken). The bearer travels down only after the code is verified.

Auth methods

Devframe owns the wire contract; the host adapter (e.g. Vite DevTools) registers handlers on the devframe/node/auth primitives (the standalone server registers a noop auto-trust when auth: false).

RPC method Direction Shape
devframe:anonymous:auth client → server { authToken, ua, origin }{ isTrusted } — re-authenticate a stored token
devframe:auth:exchange client → server { code, ua, origin }{ authToken | null } — exchange a one-time code
devframe:auth:revoked server → client event — the connection's token was revoked

Node primitives (devframe/node/auth): getTempAuthCode / refreshTempAuthCode, exchangeTempAuthCode, verifyAuthToken, buildOtpAuthUrl, revokeAuthToken.

Magic-link authentication + client utilities

A host prints buildOtpAuthUrl(origin)<origin>/?devframe_otp=<code>. By default connectDevframe reads devframe_otp, exchanges it, and strips it from the URL (only the single-use code rides the URL — never the bearer). Integrations can opt out (otpParam: false) and use the exposed devframe/client utilities: readOtpFromUrl(), consumeOtpFromUrl(), authenticateWithUrlOtp(rpc).

URL-query auth (DevTools Kit)

Preserved: the transport forwards the bearer token on the WS URL (?devframe_auth_token=…, covered by a test), and a token supplied via connectDevframe({ authToken }) is persisted across reconnects.

Security docs

New docs/guide/security.md (in the sidebar) + a SKILL "Security" section: trust model, auth flow + methods, magic-link caveats, integration opt-out, and secure-by-default practices.

Breaking changes (all unreleased)

  • devframe/utils/human-id export removed → use randomToken from devframe/utils/crypto-token.
  • devframe/node/auth drops the pending-request API (PendingAuthRequest, setPendingAuth, getPendingAuth, abortPendingAuth, consumeTempAuthToken); host adapters register devframe:auth:exchange (on exchangeTempAuthCode) and devframe:anonymous:auth (on verifyAuthToken).
  • Auth/OTP naming: DEVFRAME_OTP_URL_PARAM (devframe_otp), getTempAuthCode / refreshTempAuthCode, buildOtpAuthUrl, authenticateWithUrlOtp, and the connectDevframe option otpParam.
  • Standalone auth: false auto-trust and only-trusted gating are unchanged.

Host (Vite DevTools Kit) follow-up

Register the two handlers; show the 6-digit code and/or print buildOtpAuthUrl(origin); refresh the code (refreshTempAuthCode) when authentication begins; add a numeric OTP input; optionally set otpParam: false and consume via authenticateWithUrlOtp; surface "invalid/expired code" on a null exchange.

Verification

pnpm typecheck (now enforced monorepo-wide), pnpm lint, full vitest (407 passing), pnpm build, and pnpm docs:build pass. tsnapi snapshots for devframe/node/auth, devframe/client, and devframe/constants updated. Rebased/merged onto latest main.

This PR was created with the help of an agent.

Separate the two roles the auth flow previously conflated. The persistent
client bearer token and remote-dock tokens become high-entropy, CSPRNG
credentials, while the human-typed pairing code becomes an easy-to-enter
6-digit one-time code guarded by a short TTL and an attempt cap.

The pairing code is single-use, expires after five minutes, is compared in
constant time, and rotates after five failed attempts, so its shorter length
stays brute-force resistant. Bearer and dock tokens move off the vendored
word-list generator (which relied on Math.random) to WebCrypto-backed
randomness.

The vendored human-id helper is removed in favour of a new
`devframe/utils/crypto-token` util exposing `randomToken`, `randomDigits`,
and `timingSafeEqual`.

BREAKING CHANGE: the `devframe/utils/human-id` export is removed. Use
`randomToken` from `devframe/utils/crypto-token` for random identifiers.
@netlify

netlify Bot commented Jun 18, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

Name Link
🔨 Latest commit 542c6d7
🔍 Latest deploy log https://app.netlify.com/projects/devfra/deploys/6a34e028d8b7c30008a1eb14
😎 Deploy Preview https://deploy-preview-40--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Pivot the pairing flow from "client mints a token, node approves it" to a
node-issued exchange: the dev server shows a 6-digit code, the browser submits
it, and the node mints and returns the bearer token. The token now travels
down only after the code is verified, and the node owns token generation.

Node side (`devframe/node/auth`): drop the pending-promise machinery
(`PendingAuthRequest`, `setPendingAuth`, `getPendingAuth`, `abortPendingAuth`,
`consumeTempAuthToken`) in favour of two synchronous primitives —
`exchangeTempAuthCode` (verify code → mint + store + trust + return token) and
`verifyAuthToken` (re-auth a stored token on reconnect). The code keeps its
TTL, constant-time compare, and attempt-cap guards.

Client side: stop self-issuing a token. A fresh client connects unpaired and
calls the new `requestTrustWithCode(code)` to exchange the code for a token,
which is persisted and broadcast to sibling tabs. `requestTrustWithToken`
remains for re-auth. The client still announces on connect so the standalone
`auth: false` noop keeps auto-trusting.

The auth wire methods (`devframe:anonymous:auth`, `devframe:auth:exchange`,
`devframe:auth:revoked`) are now declared in the RPC contract types.

BREAKING CHANGE: `devframe/node/auth` no longer exports `PendingAuthRequest`,
`setPendingAuth`, `getPendingAuth`, `abortPendingAuth`, or
`consumeTempAuthToken`. Host adapters register a `devframe:auth:exchange`
handler built on `exchangeTempAuthCode`, and an `devframe:anonymous:auth`
handler built on `verifyAuthToken`, instead of the pending-request dance.
@antfubot antfubot changed the title feat(auth)!: 6-digit OTP pairing code and crypto-secure tokens feat(auth)!: node-issued one-time-code token exchange Jun 19, 2026
antfubot added 3 commits June 19, 2026 03:34
Persist a token supplied to `connectDevframe({ authToken })` so a host that
bootstraps trust from its own page-URL query survives reconnects, matching the
previous always-persist behaviour. The transport still forwards the token via
the `?devframe_auth_token=` WS query param; add a test locking that in.

Add a "Security" guide page and a SKILL section covering the trust model and
secure-by-default practices for tools built on devframe: keep `auth` on,
restrict `auth: false` to single-user localhost, treat tokens as secrets,
serve over wss beyond loopback, authorize handlers, and origin-lock remote
docks.
Let a host print a pairing link (`<origin>/?devframe_auth=<code>`) so opening
it pairs the browser automatically. `connectDevframe` reads the code, exchanges
it for a token, and strips the parameter from the URL before anything else;
the resulting bearer token is persisted, never written back to the URL.

Only the short-lived, single-use code rides the URL — never the bearer. The
behaviour is configurable via the `autoPairParam` client option (defaults to
`devframe_auth`, set `false` to disable).

Add the shared `DEVFRAME_AUTH_URL_PARAM` constant and a node `buildAuthPairingUrl`
helper for hosts to construct the link from the current code (devframe stays
headless, so the host prints its own banner). Document the flow and its
trusted-channel caveat in the security guide and skill.
…ties

Rename the magic-link query parameter from `devframe_auth` to `devframe_otp`
(and the constant to `DEVFRAME_OTP_URL_PARAM`) to distinguish it from the
bearer-token param `devframe_auth_token`.

Expose reusable client utilities from `devframe/client` so higher-level
integrations (e.g. Vite DevTools) can consume the URL OTP themselves:
`readOtpFromUrl`, `consumeOtpFromUrl` (read + strip), and `pairWithUrlOtp(rpc)`
(consume + exchange). `connectDevframe`'s built-in handling now reuses
`pairWithUrlOtp` and is opt-out via the renamed `otpParam: false` option.

Rename the node helper to `buildOtpPairingUrl` for consistency.

BREAKING CHANGE: `DEVFRAME_AUTH_URL_PARAM` → `DEVFRAME_OTP_URL_PARAM` (value
`devframe_otp`), `buildAuthPairingUrl` → `buildOtpPairingUrl`, and the
`connectDevframe` option `autoPairParam` → `otpParam`. All unreleased.
@antfubot antfubot changed the title feat(auth)!: node-issued one-time-code token exchange feat(auth)!: node-issued OTP token exchange with magic-link pairing Jun 19, 2026
antfubot added 2 commits June 19, 2026 05:42
…-exchange

# Conflicts:
#	tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts
#	tests/__snapshots__/tsnapi/devframe/client.snapshot.js
Replace the "pair/pairing" vocabulary with "authenticate/authentication"
across the auth surface, and rename the API for consistency:

- `getTempAuthToken` → `getTempAuthCode` and `refreshTempAuthToken` →
  `refreshTempAuthCode` (they handle the one-time code, not a token)
- `buildOtpPairingUrl` → `buildOtpAuthUrl`
- `pairWithUrlOtp` → `authenticateWithUrlOtp`

Document the auth methods (RPC wire contract + `devframe/node/auth` and
client primitives) and the end-to-end auth flow in the security guide.

BREAKING CHANGE: `getTempAuthToken` → `getTempAuthCode`,
`refreshTempAuthToken` → `refreshTempAuthCode`, `buildOtpPairingUrl` →
`buildOtpAuthUrl`, `pairWithUrlOtp` → `authenticateWithUrlOtp`. All unreleased.
@antfubot antfubot changed the title feat(auth)!: node-issued OTP token exchange with magic-link pairing feat(auth)!: node-issued OTP token exchange with magic-link authentication Jun 19, 2026
@antfu antfu merged commit 88212d9 into devframes:main Jun 19, 2026
12 checks passed
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.

2 participants