Skip to content

Commit fd23f6b

Browse files
antfubotopencode
andauthored
feat(cli): enable auth by default and auto-authorize --open via OTP (#124)
Co-authored-by: opencode <noreply@opencode.ai>
1 parent 5d82369 commit fd23f6b

24 files changed

Lines changed: 328 additions & 43 deletions

File tree

‎docs/adapters/cac.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ defineDevframe({
7575
portRange: [7777, 9000], // passed through to get-port-please
7676
random: false, // passed through to get-port-please
7777
host: '127.0.0.1', // default host; --host overrides
78-
open: true, // auto-open the browser on dev start
79-
auth: false, // skip the trust handshake (single-user localhost)
78+
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
8079
configure(cli) { // contribute capability flags/commands
8180
cli.option('--config <file>', 'Custom config file')
8281
.option('--no-files', 'Skip file matching')

‎docs/guide/client.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ if (!trusted) {
8080
}
8181
```
8282

83+
`connectDevframe()` kicks off that same handshake (stored token, then a magic-link OTP on the page URL, then — top-level pages only — a native prompt) the moment it resolves, without making the caller wait for it. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` know about that in-flight handshake internally and hold anything issued before it settles, so application code can call a trusted method right away — e.g. in a component's `onMount` — without an explicit `ensureTrusted()` guard just to avoid a race. Reach for `ensureTrusted()` when you want to reflect the pending state in the UI (a spinner, a "waiting for auth" banner), not to make calls safe.
84+
8385
### Authenticating with a one-time code
8486

8587
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:

‎docs/guide/devframe-definition.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,7 @@ defineDevframe({
185185
portRange: [9876, 10000], // forwarded to get-port-please
186186
random: false, // forwarded to get-port-please
187187
host: 'localhost', // default host; --host overrides
188-
open: true, // auto-open the browser on dev start
189-
auth: false, // skip the trust handshake (single-user localhost)
188+
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
190189
configure(cli) { // contribute capability flags/commands
191190
cli
192191
.option('--my-flag <value>', 'Tool-specific flag')
@@ -208,7 +207,7 @@ defineDevframe({
208207
| `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. |
209208
| `random` | `boolean` | Prefer a random open port. |
210209
| `host` | `string` | Default bind host. |
211-
| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. |
210+
| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. When `auth` is on, the opened URL embeds the current OTP so the tab authenticates automatically. |
212211
| `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. |
213212
| `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCac`'s `configureCli` option so the final tool author always has the last word. |
214213

‎docs/guide/security.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Exactly one rule decides what an untrusted connection may call: **a method is re
2424

2525
`startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036).
2626

27+
On the client, `connectDevframe()` kicks off the handshake below without waiting for it, so a naive client could otherwise race it — sending a trusted call over the freshly-opened socket before the server has had a chance to answer `anonymous:devframe:auth`, hitting this exact gate. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued while that first handshake is still in flight and release it once the handshake settles, so application code never has to special-case this window itself.
28+
2729
## Authentication flow
2830

2931
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.
@@ -79,7 +81,7 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod
7981

8082
### Magic-link authentication
8183

82-
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):
84+
To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. The standalone CLI (`createCac` / `createDevServer`) does this automatically for `--open`: when the server is auth-gated, the browser it launches already carries the current code, so the tab lands authenticated with no prompt at all. Build the link yourself from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner):
8385

8486
```
8587
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456

‎docs/guide/standalone-cli.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ const devframe = defineDevframe({
4343
distDir,
4444
port: 7777,
4545
portRange: [7777, 9000],
46-
open: true,
47-
auth: false, // single-user localhost — skip the trust handshake
46+
open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated
4847
configure(cli) {
4948
cli
5049
.option('--config <file>', 'Config file path')

‎packages/devframe/src/adapters/__tests__/dev.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { createRpcClient } from 'devframe/rpc/client'
66
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
7+
import { open } from 'devframe/utils/open'
78
import { getPort } from 'get-port-please'
89
import { describe, expect, it, vi } from 'vitest'
910
import { WebSocket } from 'ws'
1011
import { getTempAuthCode } from '../../node/auth/state'
1112
import { defineDevframe } from '../../types/devframe'
1213
import { createDevServer, resolveDevServerPort } from '../dev'
1314

15+
// `--open` is exercised directly (asserting the URL handed to the OS opener)
16+
// rather than actually launching a browser in CI.
17+
vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))
18+
1419
function connectWsClient(host: string, port: number, authToken?: string) {
1520
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
1621
{} as DevframeRpcClientFunctions,
@@ -323,6 +328,62 @@ describe('adapters/dev', () => {
323328
}
324329
})
325330

331+
it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => {
332+
const devframe = defineDevframe({
333+
id: 'devframe-auth-open',
334+
name: 'Auth Open',
335+
version: '0.0.0',
336+
packageName: 'devframe-test',
337+
homepage: 'https://example.test',
338+
description: 'Test devframe.',
339+
setup: () => {},
340+
})
341+
const host = '127.0.0.1'
342+
const port = await getPort({ port: 19415, host })
343+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
344+
const mockedOpen = vi.mocked(open)
345+
mockedOpen.mockClear()
346+
const code = getTempAuthCode()
347+
const handle = await createDevServer(devframe, { host, port, openBrowser: true })
348+
349+
try {
350+
expect(mockedOpen).toHaveBeenCalledTimes(1)
351+
const [target] = mockedOpen.mock.calls[0]
352+
expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`)
353+
}
354+
finally {
355+
spy.mockRestore()
356+
await handle.close()
357+
}
358+
})
359+
360+
it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => {
361+
const devframe = defineDevframe({
362+
id: 'devframe-auth-open-off',
363+
name: 'Auth Open Off',
364+
version: '0.0.0',
365+
packageName: 'devframe-test',
366+
homepage: 'https://example.test',
367+
description: 'Test devframe.',
368+
cli: { auth: false },
369+
setup: () => {},
370+
})
371+
const host = '127.0.0.1'
372+
const port = await getPort({ port: 19416, host })
373+
const mockedOpen = vi.mocked(open)
374+
mockedOpen.mockClear()
375+
const handle = await createDevServer(devframe, { host, port, openBrowser: true })
376+
377+
try {
378+
expect(mockedOpen).toHaveBeenCalledTimes(1)
379+
const [target] = mockedOpen.mock.calls[0]
380+
expect(target).toBe(`http://localhost:${port}/`)
381+
}
382+
finally {
383+
await handle.close()
384+
}
385+
})
386+
326387
it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => {
327388
const devframe = defineDevframe({
328389
id: 'devframe-auth-off',

‎packages/devframe/src/adapters/dev.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export async function createDevServer(
241241
// so the code is on screen by the time a browser lands on the page.
242242
authHandler?.printBanner()
243243
await options.onReady?.(info)
244-
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser)
244+
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler)
245245
},
246246
})
247247

@@ -303,6 +303,7 @@ async function maybeOpenBrowser(
303303
flags: Record<string, unknown>,
304304
origin: string,
305305
override: boolean | string | undefined,
306+
authHandler: DevframeAuthHandler | undefined,
306307
): Promise<void> {
307308
const flagsOpen = flags.open as boolean | string | undefined
308309
const cliOpen = def.cli?.open
@@ -314,8 +315,12 @@ async function maybeOpenBrowser(
314315
const target = typeof resolved === 'string'
315316
? withBase(resolved, origin)
316317
: origin
318+
// When the server is auth-gated, let the handler embed a one-time
319+
// credential (e.g. the OTP query param) in the opened URL so the tab
320+
// lands already authorized instead of prompting the user.
321+
const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target
317322
try {
318-
await open(target)
323+
await open(authorizedTarget)
319324
}
320325
catch {
321326
// Failing to launch a browser shouldn't break the dev server.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import type { DevframeRpcClientMode } from './rpc'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
// `getDevframeRpcClient` kicks off the auth bootstrap (`requestTrust`, then
6+
// the URL OTP, then a native-prompt fallback) without awaiting it, so it can
7+
// return the client immediately. The regression this guards is a caller's
8+
// very first `rpc.call(...)` — issued the moment `connectDevframe()` resolves
9+
// — racing that still-in-flight bootstrap: without a gate, the transport
10+
// sends the call over an already-open-but-not-yet-trusted socket and the
11+
// server rejects it with DF0036, even though the exact same call succeeds a
12+
// moment later once the handshake lands.
13+
//
14+
// Exercising this through a real WebSocket would mean hand-rolling birpc's
15+
// wire protocol, so instead this mocks `./rpc-ws` with a controllable fake
16+
// mode — a `requestTrust()` the test resolves on its own schedule, and a
17+
// `call` spy whose invocation timing is exactly what's under test.
18+
const fakeMode = vi.hoisted(() => {
19+
const requestTrustGate = { resolve: undefined as ((value: boolean) => void) | undefined }
20+
const call = vi.fn(async () => 'ok')
21+
const callOptional = vi.fn(async () => 'ok')
22+
const callEvent = vi.fn(async () => {})
23+
return { requestTrustGate, call, callOptional, callEvent }
24+
})
25+
26+
vi.mock('./rpc-ws', () => ({
27+
createWsRpcClientMode: vi.fn((): DevframeRpcClientMode => ({
28+
isTrusted: false,
29+
status: 'connecting',
30+
connectionError: null,
31+
ensureTrusted: async () => true,
32+
requestTrust: () => new Promise<boolean>((resolve) => {
33+
fakeMode.requestTrustGate.resolve = resolve
34+
}),
35+
requestTrustWithToken: async () => true,
36+
requestTrustWithCode: async () => null,
37+
call: fakeMode.call as DevframeRpcClientMode['call'],
38+
callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'],
39+
callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'],
40+
})),
41+
}))
42+
43+
class FakeBroadcastChannel {
44+
onmessage: ((e: any) => void) | null = null
45+
postMessage(): void {}
46+
close(): void {}
47+
}
48+
49+
const connectionMeta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
50+
51+
describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => {
52+
beforeEach(() => {
53+
fakeMode.requestTrustGate.resolve = undefined
54+
fakeMode.call.mockClear()
55+
fakeMode.callOptional.mockClear()
56+
fakeMode.callEvent.mockClear()
57+
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
58+
vi.stubGlobal('navigator', { userAgent: 'test' })
59+
vi.stubGlobal('location', {
60+
protocol: 'http:',
61+
host: 'localhost:5173',
62+
hostname: 'localhost',
63+
href: 'http://localhost:5173/index.html',
64+
origin: 'http://localhost:5173',
65+
})
66+
})
67+
68+
afterEach(() => {
69+
vi.unstubAllGlobals()
70+
})
71+
72+
it('holds `call` until the in-flight bootstrap settles, instead of sending it early', async () => {
73+
const { getDevframeRpcClient } = await import('./rpc')
74+
const rpc = await getDevframeRpcClient({
75+
connectionMeta,
76+
otpParam: false,
77+
simpleAuth: false,
78+
})
79+
80+
// Fired the instant the client is available — mirrors a component's
81+
// `onMount` calling a trusted method right after `connectDevframe()`.
82+
const pending = rpc.call('test:probe' as any)
83+
await Promise.resolve()
84+
await Promise.resolve()
85+
// The bootstrap's `requestTrust()` hasn't resolved yet — the call must
86+
// not have reached the (mocked) transport.
87+
expect(fakeMode.call).not.toHaveBeenCalled()
88+
89+
// The handshake lands.
90+
fakeMode.requestTrustGate.resolve?.(true)
91+
92+
await expect(pending).resolves.toBe('ok')
93+
expect(fakeMode.call).toHaveBeenCalledTimes(1)
94+
expect(fakeMode.call).toHaveBeenCalledWith('test:probe')
95+
})
96+
97+
it('holds `callOptional` and `callEvent` the same way', async () => {
98+
const { getDevframeRpcClient } = await import('./rpc')
99+
const rpc = await getDevframeRpcClient({
100+
connectionMeta,
101+
otpParam: false,
102+
simpleAuth: false,
103+
})
104+
105+
const pendingOptional = rpc.callOptional('test:optional' as any)
106+
const pendingEvent = rpc.callEvent('test:event' as any)
107+
await Promise.resolve()
108+
await Promise.resolve()
109+
expect(fakeMode.callOptional).not.toHaveBeenCalled()
110+
expect(fakeMode.callEvent).not.toHaveBeenCalled()
111+
112+
fakeMode.requestTrustGate.resolve?.(true)
113+
114+
await expect(pendingOptional).resolves.toBe('ok')
115+
await pendingEvent
116+
expect(fakeMode.callOptional).toHaveBeenCalledTimes(1)
117+
expect(fakeMode.callEvent).toHaveBeenCalledTimes(1)
118+
})
119+
120+
it('stops gating once the first bootstrap attempt has settled', async () => {
121+
const { getDevframeRpcClient } = await import('./rpc')
122+
const rpc = await getDevframeRpcClient({
123+
connectionMeta,
124+
otpParam: false,
125+
simpleAuth: false,
126+
})
127+
128+
fakeMode.requestTrustGate.resolve?.(true)
129+
// Let the bootstrap's own promise chain fully settle before the next
130+
// call — a few microtask ticks cover `await mode.requestTrust()`
131+
// resuming, `bootstrapAuth()` returning, and its `.then()` flipping
132+
// `bootstrapAuthSettled`.
133+
for (let i = 0; i < 5; i++)
134+
await Promise.resolve()
135+
136+
await rpc.call('test:probe' as any)
137+
// Sent straight through — no more waiting once bootstrap is over.
138+
expect(fakeMode.call).toHaveBeenCalledTimes(1)
139+
})
140+
})

‎packages/devframe/src/client/rpc.ts‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,30 @@ export async function getDevframeRpcClient(
408408
}
409409
catch {}
410410

411+
// Gate outbound calls behind the auth bootstrap kicked off below (stored
412+
// auth token, then the URL's magic-link OTP, then — standalone/top-level
413+
// only — a native prompt). Without this, a caller's very first RPC calls
414+
// (fired the moment `connectDevframe()` resolves — e.g. a component's
415+
// `onMount`) race that still-in-flight sequence: the socket is open, so
416+
// the transport happily sends them, and the server rejects them with
417+
// DF0036 because trust hasn't landed yet — even though the exact same call
418+
// would succeed a moment later. `bootstrapAuthPromise` is assigned once
419+
// `bootstrapAuth()` is kicked off further down; these closures read the
420+
// variable at call time, so the gate is live even though it's declared
421+
// before that assignment happens. Once the first bootstrap attempt has
422+
// settled (trusted, refused, or given up), `bootstrapAuthSettled` flips
423+
// for good and every later call skips the gate entirely — this only ever
424+
// holds the first wave of calls.
425+
let bootstrapAuthPromise: Promise<void> | undefined
426+
let bootstrapAuthSettled = false
427+
function gateOnBootstrapAuth<F extends (...args: any[]) => any>(fn: F): F {
428+
return ((...args: any[]) => {
429+
if (bootstrapAuthSettled || !bootstrapAuthPromise)
430+
return fn(...args)
431+
return bootstrapAuthPromise.then(() => fn(...args))
432+
}) as F
433+
}
434+
411435
const rpc: DevframeRpcClient = {
412436
events,
413437
get isTrusted() {
@@ -440,9 +464,9 @@ export async function getDevframeRpcClient(
440464
catch {}
441465
return true
442466
},
443-
call: mode.call,
444-
callEvent: mode.callEvent,
445-
callOptional: mode.callOptional,
467+
call: gateOnBootstrapAuth(mode.call),
468+
callEvent: gateOnBootstrapAuth(mode.callEvent),
469+
callOptional: gateOnBootstrapAuth(mode.callOptional),
446470
client: clientRpc,
447471
sharedState: undefined!,
448472
streaming: undefined!,
@@ -522,7 +546,14 @@ export async function getDevframeRpcClient(
522546
return
523547
await runSimpleAuthPrompt()
524548
}
525-
void bootstrapAuth()
549+
// Always resolves (never rejects) regardless of `bootstrapAuth`'s outcome —
550+
// the gate only cares that the first attempt is *over*, not whether it
551+
// succeeded; a rejection here must never leak into unrelated calls waiting
552+
// on `bootstrapAuthPromise`.
553+
bootstrapAuthPromise = bootstrapAuth().then(
554+
() => { bootstrapAuthSettled = true },
555+
() => { bootstrapAuthSettled = true },
556+
)
526557

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

‎packages/devframe/src/node/auth/handler.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,13 @@ export interface DevframeAuthHandler {
4040
* call repeatedly; it only prints once per code.
4141
*/
4242
printBanner: () => void
43+
/**
44+
* Rewrite a URL the CLI is about to open in the browser (`--open`) so the
45+
* tab lands already authenticated — e.g. appending the current OTP as a
46+
* query param. Called with the fully-resolved target URL; return it
47+
* unchanged to opt out. Optional: a handler that doesn't need this (e.g.
48+
* one gated by a pre-shared bearer token instead of an interactive code)
49+
* can omit it and the URL opens as-is.
50+
*/
51+
buildOpenUrl?: (url: string) => string
4352
}

0 commit comments

Comments
 (0)