Skip to content

Commit 1cf7e66

Browse files
authored
feat(auth): standalone simple-auth fallback with headless prompt + hub token propagation (#106)
1 parent eb62a84 commit 1cf7e66

8 files changed

Lines changed: 281 additions & 20 deletions

File tree

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

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1+
import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types'
12
import { mkdtempSync, writeFileSync } from 'node:fs'
23
import { tmpdir } from 'node:os'
34
import { join } from 'node:path'
5+
import { createRpcClient } from 'devframe/rpc/client'
6+
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
47
import { getPort } from 'get-port-please'
5-
import { describe, expect, it } from 'vitest'
8+
import { describe, expect, it, vi } from 'vitest'
69
import { WebSocket } from 'ws'
10+
import { getTempAuthCode } from '../../node/auth/state'
711
import { defineDevframe } from '../../types/devframe'
812
import { createDevServer, resolveDevServerPort } from '../dev'
913

14+
function connectWsClient(host: string, port: number, authToken?: string) {
15+
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
16+
{} as DevframeRpcClientFunctions,
17+
{ channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) },
18+
)
19+
}
20+
21+
const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' }
22+
1023
function makeTmpDist(): string {
1124
const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-'))
1225
writeFileSync(join(dir, 'index.html'), '<!doctype html><title>test</title>', 'utf-8')
@@ -271,6 +284,103 @@ describe('adapters/dev', () => {
271284
}
272285
})
273286

287+
it('gates by default: an unset `auth` auto-wires the interactive OTP handler', async () => {
288+
const devframe = defineDevframe({
289+
id: 'devframe-auth-default',
290+
name: 'Auth Default',
291+
version: '0.0.0',
292+
packageName: 'devframe-test',
293+
homepage: 'https://example.test',
294+
description: 'Test devframe.',
295+
setup: (ctx: DevframeNodeContext) => {
296+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
297+
},
298+
})
299+
const host = '127.0.0.1'
300+
const port = await getPort({ port: 19410, host })
301+
// No `banner` override is exposed through the adapter, so silence the
302+
// default stdout banner for the test run.
303+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
304+
const handle = await createDevServer(devframe, { host, port, openBrowser: false })
305+
306+
try {
307+
const client = connectWsClient(host, port)
308+
// Untrusted: only `anonymous:` methods are reachable; the probe rejects.
309+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
310+
expect(handshake).toEqual({ isTrusted: false })
311+
await expect(client.$call('test:probe' as any)).rejects.toThrow()
312+
313+
// The interactive exchange method is wired — the printed code trusts.
314+
const code = getTempAuthCode()
315+
const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null }
316+
expect(exchange.authToken).toBeTruthy()
317+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
318+
client.$close()
319+
}
320+
finally {
321+
spy.mockRestore()
322+
await handle.close()
323+
}
324+
})
325+
326+
it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => {
327+
const devframe = defineDevframe({
328+
id: 'devframe-auth-off',
329+
name: 'Auth Off',
330+
version: '0.0.0',
331+
packageName: 'devframe-test',
332+
homepage: 'https://example.test',
333+
description: 'Test devframe.',
334+
cli: { auth: false },
335+
setup: (ctx: DevframeNodeContext) => {
336+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
337+
},
338+
})
339+
const host = '127.0.0.1'
340+
const port = await getPort({ port: 19420, host })
341+
const handle = await createDevServer(devframe, { host, port, openBrowser: false })
342+
343+
try {
344+
const client = connectWsClient(host, port)
345+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
346+
expect(handshake).toEqual({ isTrusted: true })
347+
// Ungated: the probe resolves without any code exchange.
348+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
349+
client.$close()
350+
}
351+
finally {
352+
await handle.close()
353+
}
354+
})
355+
356+
it('the `--no-auth` flag (flags.auth === false) opts out of the gate', async () => {
357+
const devframe = defineDevframe({
358+
id: 'devframe-auth-flag',
359+
name: 'Auth Flag',
360+
version: '0.0.0',
361+
packageName: 'devframe-test',
362+
homepage: 'https://example.test',
363+
description: 'Test devframe.',
364+
setup: (ctx: DevframeNodeContext) => {
365+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
366+
},
367+
})
368+
const host = '127.0.0.1'
369+
const port = await getPort({ port: 19430, host })
370+
const handle = await createDevServer(devframe, { host, port, openBrowser: false, flags: { auth: false } })
371+
372+
try {
373+
const client = connectWsClient(host, port)
374+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
375+
expect(handshake).toEqual({ isTrusted: true })
376+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
377+
client.$close()
378+
}
379+
finally {
380+
await handle.close()
381+
}
382+
})
383+
274384
it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
275385
const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
276386
const devframe = defineDevframe({

packages/devframe/src/adapters/cac.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {})
6666
.option('--host <host>', 'Host to bind to', { default: defaultHost })
6767
.option('--open', 'Open the browser on start')
6868
.option('--no-open', 'Do not open the browser')
69+
// Standalone auth is on by default; `--no-auth` opts a one-off run out of
70+
// the interactive OTP gate. The `true` default CAC injects is harmless —
71+
// the dev server only acts on an explicit `auth: false`.
72+
.option('--no-auth', 'Disable the interactive authentication gate')
6973
// Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a
7074
// `true` default, silently enabling MCP. Declaring just `--mcp` yields the
7175
// opt-in tri-state — absent → `undefined` (falls through to `cli.mcp`),

packages/devframe/src/adapters/dev.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DevframeAuthHandler } from '../node/auth/handler'
12
import type { StartedServer } from '../node/server'
23
import type { ConnectionMeta } from '../types/context'
34
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
@@ -14,6 +15,7 @@ import { diagnostics } from '../node/diagnostics'
1415
import { createH3DevframeHost } from '../node/host-h3'
1516
import { startHttpAndWs } from '../node/server'
1617
import { normalizeHttpServerUrl } from '../node/utils'
18+
import { createInteractiveAuth } from '../recipes/interactive-auth'
1719
import { normalizeBasePath, resolveBasePath } from './_shared'
1820

1921
const DEFAULT_PORT = 9999
@@ -205,15 +207,39 @@ export async function createDevServer(
205207
if (distDir)
206208
mountStaticHandler(app, basePath, resolve(distDir))
207209

210+
// Resolve authentication. The standalone dev server gates by default: when
211+
// the author leaves `auth` unset (or `true`), auto-wire devframe's
212+
// interactive OTP handler and print its code + magic-link banner once the
213+
// server is listening (a gate is useless without surfacing the code). A
214+
// `false` (including the `--no-auth` flag) opts out; a handler object is
215+
// passed straight through to `startHttpAndWs`.
216+
const authOption = flags.auth === false ? false : def.cli?.auth
217+
let authHandler: DevframeAuthHandler | undefined
218+
let resolvedAuth: boolean | DevframeAuthHandler
219+
if (authOption === false) {
220+
resolvedAuth = false
221+
}
222+
else if (typeof authOption === 'object') {
223+
authHandler = authOption
224+
resolvedAuth = authOption
225+
}
226+
else {
227+
authHandler = createInteractiveAuth(ctx)
228+
resolvedAuth = authHandler
229+
}
230+
208231
const started = await startHttpAndWs({
209232
context: ctx,
210233
host,
211234
port,
212235
app,
213236
path: bindPath,
214237
wsPort,
215-
auth: def.cli?.auth,
238+
auth: resolvedAuth,
216239
onReady: async (info) => {
240+
// Print the auth banner before the caller's own onReady / browser open
241+
// so the code is on screen by the time a browser lands on the page.
242+
authHandler?.printBanner()
217243
await options.onReady?.(info)
218244
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser)
219245
},

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import { getDevframeRpcClient } from './rpc'
44

55
const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__'
6+
const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__'
67

78
// Minimal fake WebSocket: records the URL it was dialed with (all this suite
89
// needs) and never opens, so the trust handshake stays pending.
@@ -43,12 +44,14 @@ describe('getDevframeRpcClient — connection meta base', () => {
4344
origin: 'http://localhost:5173',
4445
})
4546
delete (globalThis as any)[CONNECTION_META_KEY]
47+
delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY]
4648
})
4749

4850
afterEach(() => {
4951
vi.restoreAllMocks()
5052
vi.unstubAllGlobals()
5153
delete (globalThis as any)[CONNECTION_META_KEY]
54+
delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY]
5255
})
5356

5457
it('publishes the meta annotated with the absolute base it resolved from', async () => {
@@ -83,6 +86,31 @@ describe('getDevframeRpcClient — connection meta base', () => {
8386
expect(rpc.connectionMeta.baseUrl).toBe('http://localhost:5173/__devtools/__connection.json')
8487
})
8588

89+
it('uses a token embedded in the (hub-served) connection meta as the bearer token', async () => {
90+
// A hub bakes the token into the per-frame meta so a cross-origin frame —
91+
// which can't read the hub's localStorage — is pre-authorized on connect.
92+
await getDevframeRpcClient({
93+
baseURL: '/__foo/',
94+
otpParam: false,
95+
simpleAuth: false,
96+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' },
97+
})
98+
99+
expect(lastWsUrl()).toContain('devframe_auth_token=hub-token')
100+
})
101+
102+
it('prefers an explicit authToken option over the connection-meta token', async () => {
103+
await getDevframeRpcClient({
104+
baseURL: '/__foo/',
105+
otpParam: false,
106+
simpleAuth: false,
107+
authToken: 'explicit-token',
108+
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' },
109+
})
110+
111+
expect(lastWsUrl()).toContain('devframe_auth_token=explicit-token')
112+
})
113+
86114
it('ignores a window baseUrl when connection meta is passed explicitly', async () => {
87115
;(globalThis as any)[CONNECTION_META_KEY] = {
88116
backend: 'websocket',

packages/devframe/src/client/rpc.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,25 @@ export interface DevframeRpcClientOptions {
6262
* (OTP) for "magic link" auth (e.g. a link the dev server prints). When
6363
* present, the client exchanges the code for a token and removes the parameter
6464
* from the URL. Set `false` to disable — e.g. integrations that drive their
65-
* own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`.
65+
* own authentication via `authenticateWithUrlOtp`.
66+
*
67+
* @default 'devframe_otp'
6668
*/
6769
otpParam?: string | false
70+
/**
71+
* Fall back to a native browser `prompt()` for the one-time authentication
72+
* code when the server refuses trust and no other credential succeeds (a
73+
* stored token, an injected token, or a magic-link OTP). The prompt fires
74+
* only on a **top-level, unframed** page — a framed plugin (e.g. mounted in
75+
* a hub dock) never prompts, since a hub pre-authorizes it and browsers
76+
* block `prompt()` in cross-origin frames anyway.
77+
*
78+
* Set `false` to drive your own auth UI (a hub sets this on the plugin
79+
* connections it manages, alongside supplying the token).
80+
*
81+
* @default true
82+
*/
83+
simpleAuth?: boolean
6884
wsOptions?: Partial<WsRpcChannelOptions>
6985
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
7086
cacheOptions?: boolean | Partial<RpcCacheOptions>
@@ -317,7 +333,10 @@ export async function getDevframeRpcClient(
317333
const context: DevframeRpcContext = {
318334
rpc: undefined!,
319335
}
320-
const authToken = getStoredAuthToken(options.authToken)
336+
// An explicit option wins, then a token baked into the (hub-served) meta —
337+
// the cross-origin channel a framed plugin relies on since it can't read the
338+
// hub's `localStorage` — then this origin's own stored token.
339+
const authToken = getStoredAuthToken(options.authToken || connectionMeta.authToken)
321340
// Persist a resolved token so one supplied out-of-band — e.g. a host that
322341
// bootstraps trust by passing `authToken` (read from its own page URL query)
323342
// — survives reconnects. The token is still sent to the server via the WS
@@ -450,16 +469,60 @@ export async function getDevframeRpcClient(
450469

451470
// @ts-expect-error assign to readonly property
452471
context.rpc = rpc
453-
void mode.requestTrust()
454-
455-
// Magic-link authentication: if the page URL carries a one-time code, exchange
456-
// it and strip it from the URL. The code is single-use and short-lived; the
457-
// resulting bearer token is persisted (never written back to the URL).
458-
// Integrations that drive their own auth UI opt out with `otpParam: false`
459-
// and call `authenticateWithUrlOtp` / `consumeOtpFromUrl` directly.
460-
const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM
461-
if (otpParam)
462-
void authenticateWithUrlOtp(rpc, { param: otpParam })
472+
473+
// Whether this document is the top-level, unframed page. Only there can a
474+
// native `prompt()` actually be shown — a framed plugin (hub dock) instead
475+
// waits for a hub-injected/broadcast token to arrive. Accessing
476+
// `window.top` cross-origin throws, which itself means we're framed.
477+
function isTopLevelUnframed(): boolean {
478+
try {
479+
return typeof window !== 'undefined' && window.self === window.top
480+
}
481+
catch {
482+
return false
483+
}
484+
}
485+
486+
// Last-resort standalone fallback: ask for the one-time code via the
487+
// browser's native `prompt()` (zero UI, so devframe stays headless) and
488+
// re-prompt on a wrong/expired code until the exchange succeeds or the user
489+
// cancels. Cancelling leaves the connection `unauthorized` without nagging.
490+
async function runSimpleAuthPrompt(): Promise<void> {
491+
if (options.simpleAuth === false || !isTopLevelUnframed())
492+
return
493+
if (typeof globalThis.prompt !== 'function')
494+
return
495+
while (!rpc.isTrusted) {
496+
// eslint-disable-next-line no-alert -- native prompt() is intentional: zero UI keeps devframe headless.
497+
const code = globalThis.prompt('devframe: enter the authentication code shown in your terminal')
498+
// Cancel → stop; leave status `unauthorized`.
499+
if (code == null)
500+
return
501+
const trimmed = code.trim()
502+
if (!trimmed)
503+
continue
504+
if (await rpc.requestTrustWithCode(trimmed))
505+
return
506+
}
507+
}
508+
509+
// Drive trust in order: the connect-time handshake (stored/injected token)
510+
// first, then the magic-link OTP (silent — a one-time code on the page URL,
511+
// single-use and short-lived, stripped from the URL and never re-persisted),
512+
// then the native-prompt fallback. Integrations that drive their own auth UI
513+
// opt out of the URL read with `otpParam: false` and of the prompt with
514+
// `simpleAuth: false`.
515+
async function bootstrapAuth(): Promise<void> {
516+
const trusted = await mode.requestTrust()
517+
const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM
518+
// Always consume the URL OTP (so it's stripped) even once trusted; it only
519+
// exchanges when a code is present and we're not yet trusted.
520+
const viaOtp = otpParam ? await authenticateWithUrlOtp(rpc, { param: otpParam }) : false
521+
if (trusted || viaOtp || rpc.isTrusted)
522+
return
523+
await runSimpleAuthPrompt()
524+
}
525+
void bootstrapAuth()
463526

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

packages/devframe/src/types/context.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,19 @@ export interface ConnectionMeta {
129129
* endpoint rather than resolving the path against its own mount.
130130
*/
131131
baseUrl?: string
132+
/**
133+
* A pre-issued bearer token embedded in the meta so a client trusts the
134+
* server on connect without any prompt or `localStorage` lookup. Only a
135+
* **hub** serving a **per-frame** connection meta populates this — it
136+
* authenticates once at the top level and bakes the resulting token into
137+
* the meta each plugin iframe fetches, so a cross-origin frame (which
138+
* cannot read the hub's `localStorage`) is still pre-authorized. The
139+
* standalone `__connection.json` never carries a token.
140+
*
141+
* > [!WARNING]
142+
* > A token in a fetchable JSON is only as protected as the URL serving
143+
* > it. Emit it exclusively from hub-controlled, per-frame meta — never
144+
* > from a publicly reachable static `__connection.json`.
145+
*/
146+
authToken?: string
132147
}

0 commit comments

Comments
 (0)