Skip to content

Commit 542c6d7

Browse files
committed
refactor(auth)!: use "authenticate" wording and document the auth flow
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.
1 parent 00d0729 commit 542c6d7

15 files changed

Lines changed: 119 additions & 93 deletions

File tree

docs/guide/client.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The client picks a mode automatically from the backend field. Mode-specific code
6666

6767
## Trust & auth (WebSocket mode)
6868

69-
Dev-mode connections become trusted by pairing. A client that has paired before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it:
69+
Dev-mode connections become trusted by authenticating. A client that authenticated before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it:
7070

7171
```ts
7272
const rpc = await connectDevframe()
@@ -75,11 +75,11 @@ const rpc = await connectDevframe()
7575
const trusted = await rpc.ensureTrusted()
7676

7777
if (!trusted) {
78-
console.warn('Not paired yet')
78+
console.warn('Not authenticated yet')
7979
}
8080
```
8181

82-
### Pairing with a one-time code
82+
### Authenticating with a one-time code
8383

8484
A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to `requestTrustWithCode` to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code:
8585

@@ -89,7 +89,7 @@ const ok = await rpc.requestTrustWithCode('047204')
8989

9090
The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails.
9191

92-
To pair without typing, a host can print a link embedding the code (`buildOtpPairingUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive pairing yourself with the exposed `pairWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities.
92+
To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities.
9393

9494
### Re-using an existing token
9595

@@ -101,7 +101,7 @@ const ok = await rpc.requestTrustWithToken('a1b2c3…')
101101

102102
### Broadcast-channel sync
103103

104-
`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab completes a pairing — or an auth page announces a token — every open client trusts it automatically, no reload required.
104+
`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab authenticates — or an auth page announces a token — every open client trusts it automatically, no reload required.
105105

106106
## Calling functions
107107

docs/guide/security.md

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,42 +12,65 @@ An RPC handler runs with the full privileges of the process hosting it — files
1212

1313
Two postures cover that boundary:
1414

15-
- **Authenticated (default).** `auth` defaults to `true`. The browser pairs with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and pairing UI.
15+
- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and authentication UI.
1616
- **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction.
1717

1818
> [!WARNING]
1919
> `auth: false` trusts every connection that can reach the port. Only use it when the surface is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment.
2020
21-
## Pairing and tokens
21+
## Authentication flow
2222

23-
Pairing exchanges a short code for a long token:
23+
Authentication exchanges a short code for a long-lived token. A node mints and owns the token; the browser only ever sends the short code, and only over the open socket.
2424

25-
1. The dev server shows a 6-digit one-time code in the developer's terminal.
26-
2. The developer types it into the browser, which calls `requestTrustWithCode(code)`.
27-
3. The server verifies the code, mints a high-entropy bearer token, records it as trusted, and returns it.
28-
4. The browser persists the token and presents it on reconnect; sibling tabs receive it over the `devframe-auth` channel.
25+
1. A fresh client connects unauthenticated and calls `devframe:anonymous:auth` with its stored token (empty on first run). The server returns `{ isTrusted: false }`, so the trust gate stays open while the UI prompts for a code.
26+
2. The dev server shows a 6-digit one-time code in the developer's terminal.
27+
3. The developer enters it; the browser calls `requestTrustWithCode(code)``devframe:auth:exchange`.
28+
4. The server verifies the code, mints a high-entropy bearer token, records it as trusted, marks the session trusted, and returns the token.
29+
5. The browser persists the token and presents it on reconnect (`devframe:anonymous:auth``verifyAuthToken`); sibling tabs receive it over the `devframe-auth` channel and become trusted too.
2930

3031
The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts — which is what keeps a short code brute-force resistant. Show it only in a trusted channel (the terminal), never over the network.
3132

3233
The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event.
3334

34-
### Magic-link pairing
35+
### Auth methods
3536

36-
To skip typing, a host can print a link that embeds the code and open the browser straight into a paired session. Build it from the current code with `buildOtpPairingUrl(origin)` (devframe stays headless, so the host prints its own banner):
37+
Devframe owns the wire contract; the host adapter registers the handlers on top of the `devframe/node/auth` primitives (the standalone server registers a noop auto-trust handler when `auth: false`).
38+
39+
| RPC method | Direction | Shape |
40+
|------------|-----------|-------|
41+
| `devframe:anonymous:auth` | client → server | `{ authToken, ua, origin }``{ isTrusted }` — re-authenticate a stored token |
42+
| `devframe:auth:exchange` | client → server | `{ code, ua, origin }``{ authToken \| null }` — exchange a one-time code for a token |
43+
| `devframe:auth:revoked` | server → client | event — the connection's token was revoked |
44+
45+
Node primitives (`devframe/node/auth`):
46+
47+
| Function | Role |
48+
|----------|------|
49+
| `getTempAuthCode()` / `refreshTempAuthCode()` | read / rotate the current one-time code to display |
50+
| `exchangeTempAuthCode(code, session, { ua, origin }, storage)` | verify a code, mint + store the token, trust the session, return the token (or `null`) |
51+
| `verifyAuthToken(token, session, storage)` | trust a session presenting a known token (reconnect) |
52+
| `buildOtpAuthUrl(origin, code?)` | build a magic-link URL embedding the code |
53+
| `revokeAuthToken(context, storage, token)` | delete a token and disconnect any sessions using it |
54+
55+
Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
56+
57+
### Magic-link authentication
58+
59+
To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. Build it from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner):
3760

3861
```
39-
Devtools ready — pair this browser: http://localhost:3000/?devframe_otp=123456
62+
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
4063
```
4164

4265
`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code.
4366

44-
Higher-level integrations can drive their own pairing UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `pairWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`.
67+
Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`.
4568

4669
## Practices for tools built on devframe
4770

4871
- **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do.
4972
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere.
50-
- **Treat tokens as secrets.** Never log the bearer token or the pairing code, and never bake either into build output.
73+
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output.
5174
- **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
5275
- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin.
5376
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.

packages/devframe/src/client/__tests__/otp.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest'
2-
import { consumeOtpFromUrl, pairWithUrlOtp, readOtpFromUrl } from '../otp'
2+
import { authenticateWithUrlOtp, consumeOtpFromUrl, readOtpFromUrl } from '../otp'
33

44
afterEach(() => {
55
vi.unstubAllGlobals()
@@ -46,13 +46,13 @@ describe('otp url helpers', () => {
4646
})
4747
})
4848

49-
describe('pairWithUrlOtp', () => {
49+
describe('authenticateWithUrlOtp', () => {
5050
it('exchanges the OTP via the client and resolves true on success', async () => {
5151
vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' })
5252
vi.stubGlobal('history', { state: null, replaceState: vi.fn() })
5353
const requestTrustWithCode = vi.fn().mockResolvedValue(true)
5454

55-
const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode })
55+
const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })
5656

5757
expect(requestTrustWithCode).toHaveBeenCalledWith('123456')
5858
expect(ok).toBe(true)
@@ -62,7 +62,7 @@ describe('pairWithUrlOtp', () => {
6262
vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' })
6363
const requestTrustWithCode = vi.fn()
6464

65-
const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode })
65+
const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })
6666

6767
expect(requestTrustWithCode).not.toHaveBeenCalled()
6868
expect(ok).toBe(false)
@@ -74,7 +74,7 @@ describe('pairWithUrlOtp', () => {
7474
vi.stubGlobal('history', { state: null, replaceState })
7575
const requestTrustWithCode = vi.fn()
7676

77-
const ok = await pairWithUrlOtp({ isTrusted: true, requestTrustWithCode })
77+
const ok = await authenticateWithUrlOtp({ isTrusted: true, requestTrustWithCode })
7878

7979
expect(ok).toBe(true)
8080
expect(requestTrustWithCode).not.toHaveBeenCalled()

packages/devframe/src/client/otp.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { DevframeRpcClient } from './rpc'
22
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
33

4-
// Browser-only helpers for "magic link" pairing: a host prints a URL carrying a
5-
// one-time pairing code (OTP), and the client reads it, exchanges it for a
6-
// token, and removes it from the address bar. Only the short-lived, single-use
7-
// OTP ever rides the URL — never the resulting bearer token.
4+
// Browser-only helpers for "magic link" authentication: a host prints a URL
5+
// carrying a one-time authentication code (OTP), and the client reads it,
6+
// exchanges it for a token, and removes it from the address bar. Only the
7+
// short-lived, single-use OTP ever rides the URL — never the resulting token.
88

99
/**
10-
* Read a one-time pairing code from the current page URL's query string,
11-
* without side effects. Returns `undefined` when the parameter is absent.
10+
* Read a one-time authentication code (OTP) from the current page URL's query
11+
* string, without side effects. Returns `undefined` when the parameter is absent.
1212
*/
1313
export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined {
1414
try {
@@ -46,14 +46,14 @@ export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): strin
4646
/**
4747
* Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and
4848
* exchange it for a token via the client. Resolves `true` when the client is
49-
* paired (already trusted, or the exchange succeeded), and `false` when no code
50-
* is present or the exchange failed.
49+
* authenticated (already trusted, or the exchange succeeded), and `false` when
50+
* no code is present or the exchange failed.
5151
*
5252
* Higher-level integrations (e.g. Vite DevTools) that want to drive their own
53-
* pairing UI can disable `connectDevframe`'s built-in handling with
53+
* authentication UI can disable `connectDevframe`'s built-in handling with
5454
* `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves.
5555
*/
56-
export async function pairWithUrlOtp(
56+
export async function authenticateWithUrlOtp(
5757
rpc: Pick<DevframeRpcClient, 'isTrusted' | 'requestTrustWithCode'>,
5858
options: { param?: string } = {},
5959
): Promise<boolean> {

packages/devframe/src/client/rpc-ws.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function createWsRpcClientMode(
9494

9595
isTrusted = result.isTrusted
9696
// Only settle the trust gate on success; on failure the client can still
97-
// pair via `requestTrustWithCode`, so leave `ensureTrusted` waiting.
97+
// authenticate via `requestTrustWithCode`, so leave `ensureTrusted` waiting.
9898
if (isTrusted)
9999
trustedPromise.resolve(true)
100100
events.emit('rpc:is-trusted:updated', isTrusted)
@@ -123,8 +123,9 @@ export function createWsRpcClientMode(
123123
return true
124124
// Always announce on connect. The standalone (`auth: false`) noop handler
125125
// auto-trusts regardless of token; the host adapter looks the token up and
126-
// returns `false` for an unpaired client (empty/unknown token), which then
127-
// pairs via `requestTrustWithCode`. The trust gate stays open until then.
126+
// returns `false` for an unauthenticated client (empty/unknown token), which
127+
// then authenticates via `requestTrustWithCode`. The trust gate stays open
128+
// until then.
128129
return requestTrustWithToken(currentAuthToken ?? '')
129130
}
130131

packages/devframe/src/client/rpc.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from 'devframe/constants'
1111
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
1212
import { createEventEmitter } from 'devframe/utils/events'
13-
import { pairWithUrlOtp } from './otp'
13+
import { authenticateWithUrlOtp } from './otp'
1414
import { createRpcSharedStateClientHost } from './rpc-shared-state'
1515
import { createStaticRpcClientMode } from './rpc-static'
1616
import { createRpcStreamingClientHost } from './rpc-streaming'
@@ -41,11 +41,11 @@ export interface DevframeRpcClientOptions {
4141
*/
4242
authToken?: string
4343
/**
44-
* Query-param name on the page URL carrying a one-time pairing code (OTP) for
45-
* "magic link" auth (e.g. a link the dev server prints). When present, the
46-
* client exchanges the code for a token and removes the parameter from the
47-
* URL. Set `false` to disable — e.g. integrations that drive their own
48-
* pairing via `pairWithUrlOtp`. Default: `'devframe_otp'`.
44+
* Query-param name on the page URL carrying a one-time authentication code
45+
* (OTP) for "magic link" auth (e.g. a link the dev server prints). When
46+
* present, the client exchanges the code for a token and removes the parameter
47+
* from the URL. Set `false` to disable — e.g. integrations that drive their
48+
* own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`.
4949
*/
5050
otpParam?: string | false
5151
wsOptions?: Partial<WsRpcChannelOptions>
@@ -92,9 +92,10 @@ export interface DevframeRpcClient {
9292
requestTrustWithToken: (token: string) => Promise<boolean>
9393

9494
/**
95-
* Pair this client by exchanging a one-time code (shown by the dev server)
96-
* for a node-issued auth token. On success the token is persisted for future
97-
* reconnections and shared with sibling tabs. Resolves `true` when paired.
95+
* Authenticate this client by exchanging a one-time code (shown by the dev
96+
* server) for a node-issued auth token. On success the token is persisted for
97+
* future reconnections and shared with sibling tabs. Resolves `true` when
98+
* authenticated.
9899
*/
99100
requestTrustWithCode: (code: string) => Promise<boolean>
100101

@@ -178,8 +179,8 @@ function getStoredAuthToken(userAuthToken?: string): string | undefined {
178179
catch {}
179180
}
180181

181-
// No token yet — the client is unpaired and must exchange a one-time code
182-
// (see `requestTrustWithCode`) to obtain a node-issued token.
182+
// No token yet — the client is unauthenticated and must exchange a one-time
183+
// code (see `requestTrustWithCode`) to obtain a node-issued token.
183184
return undefined
184185
}
185186

@@ -393,14 +394,14 @@ export async function getDevframeRpcClient(
393394
context.rpc = rpc
394395
void mode.requestTrust()
395396

396-
// Magic-link pairing: if the page URL carries a one-time code, exchange it
397-
// and strip it from the URL. The code is single-use and short-lived; the
397+
// Magic-link authentication: if the page URL carries a one-time code, exchange
398+
// it and strip it from the URL. The code is single-use and short-lived; the
398399
// resulting bearer token is persisted (never written back to the URL).
399-
// Integrations that drive their own pairing UI opt out with `otpParam: false`
400-
// and call `pairWithUrlOtp` / `consumeOtpFromUrl` directly.
400+
// Integrations that drive their own auth UI opt out with `otpParam: false`
401+
// and call `authenticateWithUrlOtp` / `consumeOtpFromUrl` directly.
401402
const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM
402403
if (otpParam)
403-
void pairWithUrlOtp(rpc, { param: otpParam })
404+
void authenticateWithUrlOtp(rpc, { param: otpParam })
404405

405406
// Listen for auth updates from other tabs (e.g., the auth page, or another
406407
// tab that just completed a code exchange).

packages/devframe/src/constants.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump'
1717
export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection'
1818

1919
/**
20-
* Page-URL query parameter carrying a one-time pairing code (OTP) for "magic
21-
* link" auth. A host can print a link like `<origin>/?devframe_otp=<code>`; the
22-
* client reads the code, exchanges it for a token, and strips the parameter
23-
* from the URL. See `buildOtpPairingUrl` (node) and the `pairWithUrlOtp` /
20+
* Page-URL query parameter carrying a one-time authentication code (OTP) for
21+
* "magic link" auth. A host can print a link like `<origin>/?devframe_otp=<code>`;
22+
* the client reads the code, exchanges it for a token, and strips the parameter
23+
* from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` /
2424
* `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`).
2525
*/
2626
export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp'

0 commit comments

Comments
 (0)