Skip to content

Commit 88212d9

Browse files
authored
feat(auth)!: node-issued OTP token exchange with magic-link authentication (#40)
1 parent 09f382a commit 88212d9

33 files changed

Lines changed: 659 additions & 183 deletions

alias.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export const alias = {
1818
'devframe/node': r('devframe/src/node/index.ts'),
1919
'devframe/constants': r('devframe/src/constants.ts'),
2020
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
21+
'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'),
2122
'devframe/utils/events': r('devframe/src/utils/events.ts'),
2223
'devframe/utils/hash': r('devframe/src/utils/hash.ts'),
23-
'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'),
2424
'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'),
2525
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
2626
'devframe/utils/open': r('devframe/src/utils/open.ts'),

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] {
2828
{ text: 'When Clauses', link: `${prefix}/guide/when-clauses` },
2929
{ text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` },
3030
{ text: 'Client', link: `${prefix}/guide/client` },
31+
{ text: 'Security', link: `${prefix}/guide/security` },
3132
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
3233
{ text: 'Hub (multi-tool)', link: `${prefix}/guide/hub` },
3334
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },

docs/guide/client.md

Lines changed: 18 additions & 6 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 require trust before the server accepts calls. The client handles this automatically: on first connect it submits the locally-stored auth token, and `ensureTrusted()` resolves once the server accepts.
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,21 +75,33 @@ const rpc = await connectDevframe()
7575
const trusted = await rpc.ensureTrusted()
7676

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

82-
### Replacing the token
82+
### Authenticating with a one-time code
8383

84-
For tokens supplied from a different source (e.g. copy-pasted from CLI output), swap one in without reloading:
84+
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

8686
```ts
87-
const ok = await rpc.requestTrustWithToken('another-token')
87+
const ok = await rpc.requestTrustWithCode('047204')
88+
```
89+
90+
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.
91+
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.
93+
94+
### Re-using an existing token
95+
96+
Authenticate with a token obtained elsewhere (e.g. another surface) without reloading:
97+
98+
```ts
99+
const ok = await rpc.requestTrustWithToken('a1b2c3…')
88100
```
89101

90102
### Broadcast-channel sync
91103

92-
`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with 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 tokenevery open client trusts it automatically, no reload required.
93105

94106
## Calling functions
95107

docs/guide/security.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Security
6+
7+
Devframe tools are secure by default: connections bind to `localhost`, and dev-mode RPC requires a trust handshake before a browser is accepted. This page covers the trust model and the practices that keep a tool safe as it moves beyond a single developer's machine.
8+
9+
## Trust model
10+
11+
An RPC handler runs with the full privileges of the process hosting it — filesystem, child processes, network. A trusted connection can call any registered function, so the boundary that matters is *who is allowed to connect*.
12+
13+
Two postures cover that boundary:
14+
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.
16+
- **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.
17+
18+
> [!WARNING]
19+
> `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.
20+
21+
## Authentication flow
22+
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.
24+
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.
30+
31+
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.
32+
33+
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.
34+
35+
### Auth methods
36+
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):
60+
61+
```
62+
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
63+
```
64+
65+
`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.
66+
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`.
68+
69+
## Practices for tools built on devframe
70+
71+
- **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.
72+
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere.
73+
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output.
74+
- **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.
75+
- **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.
76+
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.

docs/helpers/utilities.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,25 +74,27 @@ const wire = structuredCloneStringify(new Map([['a', 1]]))
7474
const value = structuredCloneParse<Map<string, number>>(wire)
7575
```
7676

77-
### `devframe/utils/human-id`
77+
### `devframe/utils/nanoid`
7878

79-
Generate a human-readable, lowercase, dash-separated random ID.
79+
Tiny URL-safe random ID generator (vendored, no runtime dependency).
8080

8181
```ts
82-
import { humanId } from 'devframe/utils/human-id'
82+
import { nanoid } from 'devframe/utils/nanoid'
8383

84-
humanId() // 'bright-orange-tiger'
84+
nanoid() // 21 chars
85+
nanoid(10) // 10 chars
8586
```
8687

87-
### `devframe/utils/nanoid`
88+
### `devframe/utils/crypto-token`
8889

89-
Tiny URL-safe random ID generator (vendored, no runtime dependency).
90+
Cryptographically-secure token helpers built on the WebCrypto global, so they run in browsers and Node alike. Use these for bearer credentials and human-typed one-time codes.
9091

9192
```ts
92-
import { nanoid } from 'devframe/utils/nanoid'
93+
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
9394

94-
nanoid() // 21 chars
95-
nanoid(10) // 10 chars
95+
randomToken() // 32-char hex, 128 bits of entropy — use as a bearer token
96+
randomDigits(6) // '047204' — uniform, leading zeros preserved
97+
timingSafeEqual(input, secret) // constant-time string comparison
9698
```
9799

98100
### `devframe/utils/promise`

packages/devframe/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
4141
"./types": "./dist/types/index.mjs",
4242
"./utils/colors": "./dist/utils/colors.mjs",
43+
"./utils/crypto-token": "./dist/utils/crypto-token.mjs",
4344
"./utils/events": "./dist/utils/events.mjs",
4445
"./utils/hash": "./dist/utils/hash.mjs",
45-
"./utils/human-id": "./dist/utils/human-id.mjs",
4646
"./utils/launch-editor": "./dist/utils/launch-editor.mjs",
4747
"./utils/nanoid": "./dist/utils/nanoid.mjs",
4848
"./utils/open": "./dist/utils/open.mjs",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { authenticateWithUrlOtp, consumeOtpFromUrl, readOtpFromUrl } from '../otp'
3+
4+
afterEach(() => {
5+
vi.unstubAllGlobals()
6+
})
7+
8+
describe('otp url helpers', () => {
9+
it('reads the OTP from the page URL query string (default param)', () => {
10+
vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' })
11+
expect(readOtpFromUrl()).toBe('123456')
12+
})
13+
14+
it('supports a custom param name', () => {
15+
vi.stubGlobal('location', { search: '?code=999', href: 'http://localhost:3000/?code=999' })
16+
expect(readOtpFromUrl('code')).toBe('999')
17+
})
18+
19+
it('returns undefined when the param is absent and is safe without location', () => {
20+
vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' })
21+
expect(readOtpFromUrl()).toBeUndefined()
22+
vi.stubGlobal('location', undefined)
23+
expect(readOtpFromUrl()).toBeUndefined()
24+
expect(() => consumeOtpFromUrl()).not.toThrow()
25+
})
26+
27+
it('consume reads then strips the OTP via history.replaceState, keeping other params', () => {
28+
const replaceState = vi.fn()
29+
vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' })
30+
vi.stubGlobal('history', { state: { a: 1 }, replaceState })
31+
32+
expect(consumeOtpFromUrl()).toBe('123456')
33+
expect(replaceState).toHaveBeenCalledTimes(1)
34+
const [state, , href] = replaceState.mock.calls[0]
35+
expect(state).toEqual({ a: 1 })
36+
expect(href).toBe('http://localhost:3000/?x=1')
37+
})
38+
39+
it('does not touch the URL when no OTP is present', () => {
40+
const replaceState = vi.fn()
41+
vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' })
42+
vi.stubGlobal('history', { state: null, replaceState })
43+
44+
expect(consumeOtpFromUrl()).toBeUndefined()
45+
expect(replaceState).not.toHaveBeenCalled()
46+
})
47+
})
48+
49+
describe('authenticateWithUrlOtp', () => {
50+
it('exchanges the OTP via the client and resolves true on success', async () => {
51+
vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' })
52+
vi.stubGlobal('history', { state: null, replaceState: vi.fn() })
53+
const requestTrustWithCode = vi.fn().mockResolvedValue(true)
54+
55+
const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })
56+
57+
expect(requestTrustWithCode).toHaveBeenCalledWith('123456')
58+
expect(ok).toBe(true)
59+
})
60+
61+
it('returns false (and does not exchange) when no OTP is present', async () => {
62+
vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' })
63+
const requestTrustWithCode = vi.fn()
64+
65+
const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })
66+
67+
expect(requestTrustWithCode).not.toHaveBeenCalled()
68+
expect(ok).toBe(false)
69+
})
70+
71+
it('skips the exchange but still consumes the OTP when already trusted', async () => {
72+
const replaceState = vi.fn()
73+
vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' })
74+
vi.stubGlobal('history', { state: null, replaceState })
75+
const requestTrustWithCode = vi.fn()
76+
77+
const ok = await authenticateWithUrlOtp({ isTrusted: true, requestTrustWithCode })
78+
79+
expect(ok).toBe(true)
80+
expect(requestTrustWithCode).not.toHaveBeenCalled()
81+
expect(replaceState).toHaveBeenCalledTimes(1)
82+
})
83+
})

packages/devframe/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getDevframeRpcClient } from './rpc'
22

3+
export * from './otp'
34
export * from './rpc'
45
export * from './rpc-streaming'
56
export * from './scope'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { DevframeRpcClient } from './rpc'
2+
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
3+
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.
8+
9+
/**
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.
12+
*/
13+
export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined {
14+
try {
15+
return new URLSearchParams(globalThis.location?.search).get(param) || undefined
16+
}
17+
catch {
18+
return undefined
19+
}
20+
}
21+
22+
function stripParamFromUrl(param: string): void {
23+
try {
24+
const url = new URL(globalThis.location!.href)
25+
if (!url.searchParams.has(param))
26+
return
27+
url.searchParams.delete(param)
28+
globalThis.history?.replaceState(globalThis.history.state, '', url.href)
29+
}
30+
catch {}
31+
}
32+
33+
/**
34+
* Read the one-time code from the page URL and remove it from the address bar
35+
* (and the current history entry), so the single-use code isn't left in the
36+
* URL, browser history, or a `Referer`. Returns the code, or `undefined` when
37+
* absent.
38+
*/
39+
export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined {
40+
const code = readOtpFromUrl(param)
41+
if (code)
42+
stripParamFromUrl(param)
43+
return code
44+
}
45+
46+
/**
47+
* Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and
48+
* exchange it for a token via the client. Resolves `true` when the client is
49+
* authenticated (already trusted, or the exchange succeeded), and `false` when
50+
* no code is present or the exchange failed.
51+
*
52+
* Higher-level integrations (e.g. Vite DevTools) that want to drive their own
53+
* authentication UI can disable `connectDevframe`'s built-in handling with
54+
* `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves.
55+
*/
56+
export async function authenticateWithUrlOtp(
57+
rpc: Pick<DevframeRpcClient, 'isTrusted' | 'requestTrustWithCode'>,
58+
options: { param?: string } = {},
59+
): Promise<boolean> {
60+
const code = consumeOtpFromUrl(options.param ?? DEVFRAME_OTP_URL_PARAM)
61+
if (!code)
62+
return false
63+
if (rpc.isTrusted)
64+
return true
65+
return rpc.requestTrustWithCode(code)
66+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export async function createStaticRpcClientMode(
1616
isTrusted: true,
1717
requestTrust: async () => true,
1818
requestTrustWithToken: async () => true,
19+
// Static backends are always trusted, so there's nothing to exchange.
20+
requestTrustWithCode: async () => null,
1921
ensureTrusted: async () => true,
2022
call: (...args: any): any => staticCaller.call(
2123
args[0] as string,

0 commit comments

Comments
 (0)