Skip to content

Commit cff5b16

Browse files
committed
refactor(auth)!: rename OTP url param and expose client pairing utilities
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.
1 parent e8871f9 commit cff5b16

17 files changed

Lines changed: 190 additions & 113 deletions

File tree

docs/guide/client.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 (`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.
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.
9393

9494
### Re-using an existing token
9595

docs/guide/security.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ The bearer token is a secret. It travels to the server on the WebSocket URL (`?d
3333

3434
### Magic-link pairing
3535

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):
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):
3737

3838
```
39-
Devtools ready — pair this browser: http://localhost:3000/?devframe_auth=123456
39+
Devtools ready — pair this browser: http://localhost:3000/?devframe_otp=123456
4040
```
4141

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.
42+
`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.
43+
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`.
4345

4446
## Practices for tools built on devframe
4547

packages/devframe/src/client/__tests__/auth-url.test.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.
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 { consumeOtpFromUrl, pairWithUrlOtp, 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('pairWithUrlOtp', () => {
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 pairWithUrlOtp({ 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 pairWithUrlOtp({ 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 pairWithUrlOtp({ 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/auth-url.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

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

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" 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.
8+
9+
/**
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.
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+
* paired (already trusted, or the exchange succeeded), and `false` when no code
50+
* is present or the exchange failed.
51+
*
52+
* Higher-level integrations (e.g. Vite DevTools) that want to drive their own
53+
* pairing UI can disable `connectDevframe`'s built-in handling with
54+
* `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves.
55+
*/
56+
export async function pairWithUrlOtp(
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.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +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,
87
DEVFRAME_CONNECTION_META_FILENAME,
8+
DEVFRAME_OTP_URL_PARAM,
99
} from 'devframe/constants'
1010
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
1111
import { createEventEmitter } from 'devframe/utils/events'
12-
import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from './auth-url'
12+
import { pairWithUrlOtp } from './otp'
1313
import { createRpcSharedStateClientHost } from './rpc-shared-state'
1414
import { createStaticRpcClientMode } from './rpc-static'
1515
import { createRpcStreamingClientHost } from './rpc-streaming'
@@ -39,12 +39,13 @@ export interface DevframeRpcClientOptions {
3939
*/
4040
authToken?: string
4141
/**
42-
* Query-param name on the page URL carrying a one-time pairing code for
42+
* Query-param name on the page URL carrying a one-time pairing code (OTP) for
4343
* "magic link" auth (e.g. a link the dev server prints). When present, the
4444
* client exchanges the code for a token and removes the parameter from the
45-
* URL. Set `false` to disable. Default: `'devframe_auth'`.
45+
* URL. Set `false` to disable — e.g. integrations that drive their own
46+
* pairing via `pairWithUrlOtp`. Default: `'devframe_otp'`.
4647
*/
47-
autoPairParam?: string | false
48+
otpParam?: string | false
4849
wsOptions?: Partial<WsRpcChannelOptions>
4950
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
5051
cacheOptions?: boolean | Partial<RpcCacheOptions>
@@ -364,15 +365,11 @@ export async function getDevframeRpcClient(
364365
// Magic-link pairing: if the page URL carries a one-time code, exchange it
365366
// and strip it from the URL. The code is single-use and short-lived; the
366367
// 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-
}
368+
// Integrations that drive their own pairing UI opt out with `otpParam: false`
369+
// and call `pairWithUrlOtp` / `consumeOtpFromUrl` directly.
370+
const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM
371+
if (otpParam)
372+
void pairWithUrlOtp(rpc, { param: otpParam })
376373

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

packages/devframe/src/constants.ts

Lines changed: 5 additions & 5 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 for "magic link"
21-
* auth. A host can print a link like `<origin>/?devframe_auth=<code>`; the
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
2222
* 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).
23+
* from the URL. See `buildOtpPairingUrl` (node) and the `pairWithUrlOtp` /
24+
* `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`).
2525
*/
26-
export const DEVFRAME_AUTH_URL_PARAM = 'devframe_auth'
26+
export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp'

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +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'
4+
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'
55
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
66

77
/** Number of decimal digits in a human-typed one-time pairing code. */
@@ -44,14 +44,14 @@ export function refreshTempAuthToken(): string {
4444
}
4545

4646
/**
47-
* Build a "magic link" pairing URL that embeds a one-time code as a query
47+
* Build a "magic link" pairing URL that embeds a one-time code (OTP) as a query
4848
* parameter. Opening it lets the client pair without typing — print it on
4949
* startup (devframe stays headless, so the host prints its own banner).
5050
* Defaults to the current code; the link is subject to the same TTL.
5151
*/
52-
export function buildAuthPairingUrl(baseUrl: string, code: string = tempAuthCode): string {
52+
export function buildOtpPairingUrl(baseUrl: string, code: string = tempAuthCode): string {
5353
const url = new URL(baseUrl)
54-
url.searchParams.set(DEVFRAME_AUTH_URL_PARAM, code)
54+
url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code)
5555
return url.href
5656
}
5757

0 commit comments

Comments
 (0)