Skip to content

Commit b47a934

Browse files
committed
feat(auth)!: node-issued one-time-code token exchange
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.
1 parent de3b203 commit b47a934

9 files changed

Lines changed: 197 additions & 127 deletions

File tree

docs/guide/client.md

Lines changed: 16 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 pairing. A client that has paired 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,31 @@ const rpc = await connectDevframe()
7575
const trusted = await rpc.ensureTrusted()
7676

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

82-
### Replacing the token
82+
### Pairing 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+
### Re-using an existing token
93+
94+
Authenticate with a token obtained elsewhere (e.g. another surface) without reloading:
95+
96+
```ts
97+
const ok = await rpc.requestTrustWithToken('a1b2c3…')
8898
```
8999

90100
### Broadcast-channel sync
91101

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.
102+
`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 tokenevery open client trusts it automatically, no reload required.
93103

94104
## Calling functions
95105

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,

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { promiseWithResolver } from 'devframe/utils/promise'
66
import { parseUA } from 'ua-parser-modern'
77

88
export interface CreateWsRpcClientModeOptions {
9-
authToken: string
9+
authToken?: string
1010
connectionMeta: ConnectionMeta
1111
events: EventEmitter<RpcClientEvents>
1212
clientRpc: DevframeClientRpcHost
@@ -69,37 +69,63 @@ export function createWsRpcClientMode(
6969
},
7070
})
7171

72-
let currentAuthToken = authToken
73-
74-
async function requestTrustWithToken(token: string) {
75-
currentAuthToken = token
72+
let currentAuthToken: string | undefined = authToken
7673

74+
function describeUA(): string {
7775
const info = parseUA(navigator.userAgent)
78-
const ua = [
76+
return [
7977
info.browser.name,
8078
info.browser.version,
8179
'|',
8280
info.os.name,
8381
info.os.version,
8482
info.device.type,
8583
].filter(i => i).join(' ')
84+
}
85+
86+
async function requestTrustWithToken(token: string) {
87+
currentAuthToken = token
8688

8789
const result = await serverRpc.$call('devframe:anonymous:auth', {
8890
authToken: token,
89-
ua,
91+
ua: describeUA(),
9092
origin: location.origin,
9193
})
9294

9395
isTrusted = result.isTrusted
94-
trustedPromise.resolve(isTrusted)
96+
// Only settle the trust gate on success; on failure the client can still
97+
// pair via `requestTrustWithCode`, so leave `ensureTrusted` waiting.
98+
if (isTrusted)
99+
trustedPromise.resolve(true)
95100
events.emit('rpc:is-trusted:updated', isTrusted)
96101
return result.isTrusted
97102
}
98103

104+
async function requestTrustWithCode(code: string): Promise<string | null> {
105+
const result = await serverRpc.$call('devframe:auth:exchange', {
106+
code,
107+
ua: describeUA(),
108+
origin: location.origin,
109+
})
110+
111+
const token = result?.authToken ?? null
112+
if (token) {
113+
currentAuthToken = token
114+
isTrusted = true
115+
trustedPromise.resolve(true)
116+
events.emit('rpc:is-trusted:updated', true)
117+
}
118+
return token
119+
}
120+
99121
async function requestTrust() {
100122
if (isTrusted)
101123
return true
102-
return requestTrustWithToken(currentAuthToken)
124+
// Always announce on connect. The standalone (`auth: false`) noop handler
125+
// 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.
128+
return requestTrustWithToken(currentAuthToken ?? '')
103129
}
104130

105131
async function ensureTrusted(timeout = 60_000): Promise<boolean> {
@@ -129,6 +155,7 @@ export function createWsRpcClientMode(
129155
},
130156
requestTrust,
131157
requestTrustWithToken,
158+
requestTrustWithCode,
132159
ensureTrusted,
133160
call: (...args: any): any => {
134161
return serverRpc.$call(

packages/devframe/src/client/rpc.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
DEVFRAME_CONNECTION_META_FILENAME,
88
} from 'devframe/constants'
99
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
10-
import { randomToken } from 'devframe/utils/crypto-token'
1110
import { createEventEmitter } from 'devframe/utils/events'
1211
import { createRpcSharedStateClientHost } from './rpc-shared-state'
1312
import { createStaticRpcClientMode } from './rpc-static'
@@ -75,11 +74,18 @@ export interface DevframeRpcClient {
7574
requestTrust: () => Promise<boolean>
7675

7776
/**
78-
* Request trust from the server using a specific auth token.
77+
* Request trust from the server using a previously-issued auth token.
7978
* Updates the stored token and re-requests trust without reloading the page.
8079
*/
8180
requestTrustWithToken: (token: string) => Promise<boolean>
8281

82+
/**
83+
* Pair this client by exchanging a one-time code (shown by the dev server)
84+
* for a node-issued auth token. On success the token is persisted for future
85+
* reconnections and shared with sibling tabs. Resolves `true` when paired.
86+
*/
87+
requestTrustWithCode: (code: string) => Promise<boolean>
88+
8389
/**
8490
* Call a RPC function on the server
8591
*/
@@ -118,37 +124,45 @@ export interface DevframeRpcClientMode {
118124
ensureTrusted: DevframeRpcClient['ensureTrusted']
119125
requestTrust: DevframeRpcClient['requestTrust']
120126
requestTrustWithToken: DevframeRpcClient['requestTrustWithToken']
127+
/**
128+
* Exchange a one-time code for a node-issued token. Resolves the minted
129+
* token on success (for the caller to persist), or `null` on failure.
130+
*/
131+
requestTrustWithCode: (code: string) => Promise<string | null>
121132
call: DevframeRpcClient['call']
122133
callEvent: DevframeRpcClient['callEvent']
123134
callOptional: DevframeRpcClient['callOptional']
124135
}
125136

126-
function getConnectionAuthTokenFromWindows(userAuthToken?: string): string {
137+
function getStoredAuthToken(userAuthToken?: string): string | undefined {
127138
const getters = [
128139
() => userAuthToken,
129-
() => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY),
140+
() => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined,
130141
() => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY],
131142
() => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY],
132143
() => (parent.window as any)?.[CONNECTION_AUTH_TOKEN_KEY],
133144
]
134145

135-
let value: string | undefined
136-
137146
for (const getter of getters) {
138147
try {
139-
value = getter()
148+
const value = getter()
140149
if (value)
141-
break
150+
return value
142151
}
143152
catch {}
144153
}
145154

146-
if (!value)
147-
value = randomToken()
155+
// No token yet — the client is unpaired and must exchange a one-time code
156+
// (see `requestTrustWithCode`) to obtain a node-issued token.
157+
return undefined
158+
}
148159

149-
localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value)
150-
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value
151-
return value
160+
function persistAuthToken(token: string): void {
161+
try {
162+
localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token)
163+
}
164+
catch {}
165+
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token
152166
}
153167

154168
function findConnectionMetaFromWindows(): ConnectionMeta | undefined {
@@ -223,7 +237,7 @@ export async function getDevframeRpcClient(
223237
const context: DevframeRpcContext = {
224238
rpc: undefined!,
225239
}
226-
const authToken = getConnectionAuthTokenFromWindows(options.authToken)
240+
const authToken = getStoredAuthToken(options.authToken)
227241
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(context)
228242

229243
async function fetchJsonFromBases(path: string): Promise<any> {
@@ -283,6 +297,13 @@ export async function getDevframeRpcClient(
283297
wsOptions: options.wsOptions,
284298
})
285299

300+
// Channel name kept for cross-tab interop with the Vite DevTools auth page.
301+
let authChannel: BroadcastChannel | undefined
302+
try {
303+
authChannel = new BroadcastChannel('devframe-auth')
304+
}
305+
catch {}
306+
286307
const rpc: DevframeRpcClient = {
287308
events,
288309
get isTrusted() {
@@ -293,10 +314,22 @@ export async function getDevframeRpcClient(
293314
requestTrust: mode.requestTrust,
294315
requestTrustWithToken: async (token: string) => {
295316
// Update stored token for future reconnections
296-
localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token)
297-
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token
317+
persistAuthToken(token)
298318
return mode.requestTrustWithToken(token)
299319
},
320+
requestTrustWithCode: async (code: string) => {
321+
const token = await mode.requestTrustWithCode(code)
322+
if (!token)
323+
return false
324+
// Persist the node-issued token and share it with sibling tabs so they
325+
// become trusted without re-entering the code.
326+
persistAuthToken(token)
327+
try {
328+
authChannel?.postMessage({ type: 'auth-update', authToken: token })
329+
}
330+
catch {}
331+
return true
332+
},
300333
call: mode.call,
301334
callEvent: mode.callEvent,
302335
callOptional: mode.callOptional,
@@ -313,17 +346,15 @@ export async function getDevframeRpcClient(
313346
context.rpc = rpc
314347
void mode.requestTrust()
315348

316-
// Listen for auth updates from other tabs (e.g., auth URL page).
317-
// Channel name kept for cross-tab interop with the Vite DevTools auth page.
318-
try {
319-
const bc = new BroadcastChannel('devframe-auth')
320-
bc.onmessage = (event) => {
349+
// Listen for auth updates from other tabs (e.g., the auth page, or another
350+
// tab that just completed a code exchange).
351+
if (authChannel) {
352+
authChannel.onmessage = (event) => {
321353
if (event.data?.type === 'auth-update' && event.data.authToken) {
322354
rpc.requestTrustWithToken(event.data.authToken)
323355
}
324356
}
325357
}
326-
catch {}
327358

328359
return rpc
329360
}

0 commit comments

Comments
 (0)