Skip to content

Commit e8871f9

Browse files
committed
feat(auth): magic-link pairing via one-time code in the URL
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.
1 parent 5ead924 commit e8871f9

13 files changed

Lines changed: 142 additions & 0 deletions

File tree

docs/guide/client.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ 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 (`buildAuthPairingUrl(origin)`); `connectDevframe` reads the `devframe_auth` query parameter, exchanges it, and strips it from the URL. Disable or rename it with the `autoPairParam` option.
93+
9294
### Re-using an existing token
9395

9496
Authenticate with a token obtained elsewhere (e.g. another surface) without reloading:

docs/guide/security.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ The 6-digit code is single-use, expires after five minutes, is compared in const
3131

3232
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.
3333

34+
### Magic-link pairing
35+
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 `buildAuthPairingUrl(origin)` (devframe stays headless, so the host prints its own banner):
37+
38+
```
39+
Devtools ready — pair this browser: http://localhost:3000/?devframe_auth=123456
40+
```
41+
42+
`connectDevframe` reads the `devframe_auth` parameter, exchanges it, and removes it from the URL before anything else (configurable via the `autoPairParam` client option). 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.
43+
3444
## Practices for tools built on devframe
3545

3646
- **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.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from '../auth-url'
3+
4+
afterEach(() => {
5+
vi.unstubAllGlobals()
6+
})
7+
8+
describe('auth-url helpers', () => {
9+
it('reads the pairing code from the page URL query string', () => {
10+
vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' })
11+
expect(readAuthCodeFromUrl('devframe_auth')).toBe('123456')
12+
})
13+
14+
it('returns undefined when the param is absent or empty', () => {
15+
vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' })
16+
expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined()
17+
})
18+
19+
it('is safe when location is unavailable', () => {
20+
vi.stubGlobal('location', undefined)
21+
expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined()
22+
expect(() => clearAuthCodeFromUrl('devframe_auth')).not.toThrow()
23+
})
24+
25+
it('strips the pairing code from the URL via history.replaceState, keeping other params', () => {
26+
const replaceState = vi.fn()
27+
vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' })
28+
vi.stubGlobal('history', { state: { a: 1 }, replaceState })
29+
30+
clearAuthCodeFromUrl('devframe_auth')
31+
32+
expect(replaceState).toHaveBeenCalledTimes(1)
33+
const [state, , href] = replaceState.mock.calls[0]
34+
expect(state).toEqual({ a: 1 })
35+
expect(href).toBe('http://localhost:3000/?x=1')
36+
})
37+
38+
it('does nothing when the param is not present in the URL', () => {
39+
const replaceState = vi.fn()
40+
vi.stubGlobal('location', { href: 'http://localhost:3000/?x=1' })
41+
vi.stubGlobal('history', { state: null, replaceState })
42+
43+
clearAuthCodeFromUrl('devframe_auth')
44+
45+
expect(replaceState).not.toHaveBeenCalled()
46+
})
47+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Browser-only helpers for "magic link" pairing: a host can print a URL that
2+
// carries a one-time pairing code, and the client reads the code, exchanges it
3+
// for a token, and removes it from the address bar. Only the short-lived,
4+
// single-use code ever rides the URL — never the resulting bearer token.
5+
6+
/**
7+
* Read a one-time pairing code from the current page URL's query string.
8+
* Returns `undefined` when the parameter is absent or unavailable.
9+
*/
10+
export function readAuthCodeFromUrl(param: string): string | undefined {
11+
try {
12+
return new URLSearchParams(globalThis.location?.search).get(param) || undefined
13+
}
14+
catch {
15+
return undefined
16+
}
17+
}
18+
19+
/**
20+
* Remove the pairing-code parameter from the address bar (and the current
21+
* history entry) so the single-use code isn't left in the URL, browser
22+
* history, or a `Referer` header.
23+
*/
24+
export function clearAuthCodeFromUrl(param: string): void {
25+
try {
26+
const url = new URL(globalThis.location!.href)
27+
if (!url.searchParams.has(param))
28+
return
29+
url.searchParams.delete(param)
30+
globalThis.history?.replaceState(globalThis.history.state, '', url.href)
31+
}
32+
catch {}
33+
}

packages/devframe/src/client/rpc.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client'
44
import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types'
55
import type { RpcStreamingClientHost } from './rpc-streaming'
66
import {
7+
DEVFRAME_AUTH_URL_PARAM,
78
DEVFRAME_CONNECTION_META_FILENAME,
89
} from 'devframe/constants'
910
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
1011
import { createEventEmitter } from 'devframe/utils/events'
12+
import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from './auth-url'
1113
import { createRpcSharedStateClientHost } from './rpc-shared-state'
1214
import { createStaticRpcClientMode } from './rpc-static'
1315
import { createRpcStreamingClientHost } from './rpc-streaming'
@@ -36,6 +38,13 @@ export interface DevframeRpcClientOptions {
3638
* The auth token to use for the client
3739
*/
3840
authToken?: string
41+
/**
42+
* Query-param name on the page URL carrying a one-time pairing code for
43+
* "magic link" auth (e.g. a link the dev server prints). When present, the
44+
* client exchanges the code for a token and removes the parameter from the
45+
* URL. Set `false` to disable. Default: `'devframe_auth'`.
46+
*/
47+
autoPairParam?: string | false
3948
wsOptions?: Partial<WsRpcChannelOptions>
4049
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
4150
cacheOptions?: boolean | Partial<RpcCacheOptions>
@@ -352,6 +361,19 @@ export async function getDevframeRpcClient(
352361
context.rpc = rpc
353362
void mode.requestTrust()
354363

364+
// Magic-link pairing: if the page URL carries a one-time code, exchange it
365+
// and strip it from the URL. The code is single-use and short-lived; the
366+
// resulting bearer token is persisted (never written back to the URL).
367+
const autoPairParam = options.autoPairParam ?? DEVFRAME_AUTH_URL_PARAM
368+
if (autoPairParam) {
369+
const code = readAuthCodeFromUrl(autoPairParam)
370+
if (code) {
371+
clearAuthCodeFromUrl(autoPairParam)
372+
if (!rpc.isTrusted)
373+
void rpc.requestTrustWithCode(code)
374+
}
375+
}
376+
355377
// Listen for auth updates from other tabs (e.g., the auth page, or another
356378
// tab that just completed a code exchange).
357379
if (authChannel) {

packages/devframe/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump'
1515
* `@vitejs/devtools-kit`) injected into remote-UI iframe dock URLs.
1616
*/
1717
export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection'
18+
19+
/**
20+
* Page-URL query parameter carrying a one-time pairing code for "magic link"
21+
* auth. A host can print a link like `<origin>/?devframe_auth=<code>`; the
22+
* client reads the code, exchanges it for a token, and strips the parameter
23+
* from the URL. See `buildAuthPairingUrl` (node) and `connectDevframe`'s
24+
* `autoPairParam` option (client).
25+
*/
26+
export const DEVFRAME_AUTH_URL_PARAM = 'devframe_auth'

packages/devframe/src/node/auth/state.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeNodeRpcSession } from 'devframe/types'
22
import type { SharedState } from 'devframe/utils/shared-state'
33
import type { InternalAnonymousAuthStorage } from '../hub-internals/context'
4+
import { DEVFRAME_AUTH_URL_PARAM } from 'devframe/constants'
45
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
56

67
/** Number of decimal digits in a human-typed one-time pairing code. */
@@ -42,6 +43,18 @@ export function refreshTempAuthToken(): string {
4243
return tempAuthCode
4344
}
4445

46+
/**
47+
* Build a "magic link" pairing URL that embeds a one-time code as a query
48+
* parameter. Opening it lets the client pair without typing — print it on
49+
* startup (devframe stays headless, so the host prints its own banner).
50+
* Defaults to the current code; the link is subject to the same TTL.
51+
*/
52+
export function buildAuthPairingUrl(baseUrl: string, code: string = tempAuthCode): string {
53+
const url = new URL(baseUrl)
54+
url.searchParams.set(DEVFRAME_AUTH_URL_PARAM, code)
55+
return url.href
56+
}
57+
4558
/**
4659
* Re-authenticate a connection that presents a previously-issued bearer token.
4760
* Returns `true` and marks the session trusted when the token is known.

skills/devframe/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t
485485
- **`auth` defaults to `true`** — dev-mode connections must pair before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and pairing UI.
486486
- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never pair it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`.
487487
- **Pairing** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network.
488+
- **Magic-link (optional):** print `buildAuthPairingUrl(origin)``<origin>/?devframe_auth=<code>`. `connectDevframe` reads the code, exchanges it, and strips it from the URL (`autoPairParam` to disable/rename). Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself.
488489
- **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`.
489490
- **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first.
490491
- **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin.

tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface DevframeRpcClientOptions {
3232
connectionMeta?: ConnectionMeta;
3333
baseURL?: string | string[];
3434
authToken?: string;
35+
autoPairParam?: string | false;
3536
wsOptions?: Partial<WsRpcChannelOptions>;
3637
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>;
3738
cacheOptions?: boolean | Partial<RpcCacheOptions>;

tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Generated by tsnapi — public API snapshot of `devframe/constants`
33
*/
44
// #region Variables
5+
export declare const DEVFRAME_AUTH_URL_PARAM: string;
56
export declare const DEVFRAME_CONNECTION_META_FILENAME: string;
67
export declare const DEVFRAME_DIRNAME: string;
78
export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string;

0 commit comments

Comments
 (0)