feat(auth)!: node-issued OTP token exchange with magic-link authentication#40
Merged
Merged
Conversation
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.
✅ Deploy Preview for devfra ready!
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.
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.
…-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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Reworks dev-mode auth to balance UX, security, and dependency footprint, and removes the vendored
human-iddependency.human-idword generator with a runtime-agnosticdevframe/utils/crypto-tokenutil (randomToken/randomDigits/timingSafeEqual, WebCrypto CSPRNG). Bearer and remote-dock tokens are now 128-bit CSPRNG values instead ofMath.randomword-pairs.devframe_otp) — optional URL bootstrap with reusable client utilities so integrations can drive their own flow.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:auth→verifyAuthToken). 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/authprimitives (the standalone server registers a noop auto-trust whenauth: false).devframe:anonymous:auth{ authToken, ua, origin }→{ isTrusted }— re-authenticate a stored tokendevframe:auth:exchange{ code, ua, origin }→{ authToken | null }— exchange a one-time codedevframe:auth:revokedNode 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 defaultconnectDevframereadsdevframe_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 exposeddevframe/clientutilities: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 viaconnectDevframe({ 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-idexport removed → userandomTokenfromdevframe/utils/crypto-token.devframe/node/authdrops the pending-request API (PendingAuthRequest,setPendingAuth,getPendingAuth,abortPendingAuth,consumeTempAuthToken); host adapters registerdevframe:auth:exchange(onexchangeTempAuthCode) anddevframe:anonymous:auth(onverifyAuthToken).DEVFRAME_OTP_URL_PARAM(devframe_otp),getTempAuthCode/refreshTempAuthCode,buildOtpAuthUrl,authenticateWithUrlOtp, and theconnectDevframeoptionotpParam.auth: falseauto-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 setotpParam: falseand consume viaauthenticateWithUrlOtp; surface "invalid/expired code" on anullexchange.Verification
pnpm typecheck(now enforced monorepo-wide),pnpm lint, fullvitest(407 passing),pnpm build, andpnpm docs:buildpass. tsnapi snapshots fordevframe/node/auth,devframe/client, anddevframe/constantsupdated. Rebased/merged onto latestmain.This PR was created with the help of an agent.