diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index f2b8f988..39242b61 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -1,12 +1,25 @@ +import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' +import { getTempAuthCode } from '../../node/auth/state' import { defineDevframe } from '../../types/devframe' import { createDevServer, resolveDevServerPort } from '../dev' +function connectWsClient(host: string, port: number, authToken?: string) { + return createRpcClient( + {} as DevframeRpcClientFunctions, + { channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) }, + ) +} + +const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' } + function makeTmpDist(): string { const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-')) writeFileSync(join(dir, 'index.html'), 'test', 'utf-8') @@ -271,6 +284,103 @@ describe('adapters/dev', () => { } }) + it('gates by default: an unset `auth` auto-wires the interactive OTP handler', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-default', + name: 'Auth Default', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19410, host }) + // No `banner` override is exposed through the adapter, so silence the + // default stdout banner for the test run. + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const client = connectWsClient(host, port) + // Untrusted: only `anonymous:` methods are reachable; the probe rejects. + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: false }) + await expect(client.$call('test:probe' as any)).rejects.toThrow() + + // The interactive exchange method is wired — the printed code trusts. + const code = getTempAuthCode() + const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null } + expect(exchange.authToken).toBeTruthy() + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + spy.mockRestore() + await handle.close() + } + }) + + it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-off', + name: 'Auth Off', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + cli: { auth: false }, + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19420, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const client = connectWsClient(host, port) + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: true }) + // Ungated: the probe resolves without any code exchange. + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await handle.close() + } + }) + + it('the `--no-auth` flag (flags.auth === false) opts out of the gate', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-flag', + name: 'Auth Flag', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19430, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false, flags: { auth: false } }) + + try { + const client = connectWsClient(host, port) + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: true }) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await handle.close() + } + }) + it('resolveDevServerPort honors def.cli.port as the preferred default', async () => { const preferred = await getPort({ port: 19500, host: '127.0.0.1' }) const devframe = defineDevframe({ diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index 804cab35..c51bc05b 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -66,6 +66,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) .option('--host ', 'Host to bind to', { default: defaultHost }) .option('--open', 'Open the browser on start') .option('--no-open', 'Do not open the browser') + // Standalone auth is on by default; `--no-auth` opts a one-off run out of + // the interactive OTP gate. The `true` default CAC injects is harmless — + // the dev server only acts on an explicit `auth: false`. + .option('--no-auth', 'Disable the interactive authentication gate') // Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a // `true` default, silently enabling MCP. Declaring just `--mcp` yields the // opt-in tri-state — absent → `undefined` (falls through to `cli.mcp`), diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 7ede0194..454422ab 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,3 +1,4 @@ +import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' @@ -14,6 +15,7 @@ import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' import { normalizeHttpServerUrl } from '../node/utils' +import { createInteractiveAuth } from '../recipes/interactive-auth' import { normalizeBasePath, resolveBasePath } from './_shared' const DEFAULT_PORT = 9999 @@ -205,6 +207,27 @@ export async function createDevServer( if (distDir) mountStaticHandler(app, basePath, resolve(distDir)) + // Resolve authentication. The standalone dev server gates by default: when + // the author leaves `auth` unset (or `true`), auto-wire devframe's + // interactive OTP handler and print its code + magic-link banner once the + // server is listening (a gate is useless without surfacing the code). A + // `false` (including the `--no-auth` flag) opts out; a handler object is + // passed straight through to `startHttpAndWs`. + const authOption = flags.auth === false ? false : def.cli?.auth + let authHandler: DevframeAuthHandler | undefined + let resolvedAuth: boolean | DevframeAuthHandler + if (authOption === false) { + resolvedAuth = false + } + else if (typeof authOption === 'object') { + authHandler = authOption + resolvedAuth = authOption + } + else { + authHandler = createInteractiveAuth(ctx) + resolvedAuth = authHandler + } + const started = await startHttpAndWs({ context: ctx, host, @@ -212,8 +235,11 @@ export async function createDevServer( app, path: bindPath, wsPort, - auth: def.cli?.auth, + auth: resolvedAuth, onReady: async (info) => { + // Print the auth banner before the caller's own onReady / browser open + // so the code is on screen by the time a browser lands on the page. + authHandler?.printBanner() await options.onReady?.(info) await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) }, diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts index 7d9b3cf7..118c97bc 100644 --- a/packages/devframe/src/client/rpc.test.ts +++ b/packages/devframe/src/client/rpc.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDevframeRpcClient } from './rpc' const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' // Minimal fake WebSocket: records the URL it was dialed with (all this suite // needs) and never opens, so the trust handshake stays pending. @@ -43,12 +44,14 @@ describe('getDevframeRpcClient — connection meta base', () => { origin: 'http://localhost:5173', }) delete (globalThis as any)[CONNECTION_META_KEY] + delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() delete (globalThis as any)[CONNECTION_META_KEY] + delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] }) it('publishes the meta annotated with the absolute base it resolved from', async () => { @@ -83,6 +86,31 @@ describe('getDevframeRpcClient — connection meta base', () => { expect(rpc.connectionMeta.baseUrl).toBe('http://localhost:5173/__devtools/__connection.json') }) + it('uses a token embedded in the (hub-served) connection meta as the bearer token', async () => { + // A hub bakes the token into the per-frame meta so a cross-origin frame — + // which can't read the hub's localStorage — is pre-authorized on connect. + await getDevframeRpcClient({ + baseURL: '/__foo/', + otpParam: false, + simpleAuth: false, + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' }, + }) + + expect(lastWsUrl()).toContain('devframe_auth_token=hub-token') + }) + + it('prefers an explicit authToken option over the connection-meta token', async () => { + await getDevframeRpcClient({ + baseURL: '/__foo/', + otpParam: false, + simpleAuth: false, + authToken: 'explicit-token', + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' }, + }) + + expect(lastWsUrl()).toContain('devframe_auth_token=explicit-token') + }) + it('ignores a window baseUrl when connection meta is passed explicitly', async () => { ;(globalThis as any)[CONNECTION_META_KEY] = { backend: 'websocket', diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 95742e68..5b1be907 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -62,9 +62,25 @@ export interface DevframeRpcClientOptions { * (OTP) for "magic link" auth (e.g. a link the dev server prints). When * present, the client exchanges the code for a token and removes the parameter * from the URL. Set `false` to disable — e.g. integrations that drive their - * own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`. + * own authentication via `authenticateWithUrlOtp`. + * + * @default 'devframe_otp' */ otpParam?: string | false + /** + * Fall back to a native browser `prompt()` for the one-time authentication + * code when the server refuses trust and no other credential succeeds (a + * stored token, an injected token, or a magic-link OTP). The prompt fires + * only on a **top-level, unframed** page — a framed plugin (e.g. mounted in + * a hub dock) never prompts, since a hub pre-authorizes it and browsers + * block `prompt()` in cross-origin frames anyway. + * + * Set `false` to drive your own auth UI (a hub sets this on the plugin + * connections it manages, alongside supplying the token). + * + * @default true + */ + simpleAuth?: boolean wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -317,7 +333,10 @@ export async function getDevframeRpcClient( const context: DevframeRpcContext = { rpc: undefined!, } - const authToken = getStoredAuthToken(options.authToken) + // An explicit option wins, then a token baked into the (hub-served) meta — + // the cross-origin channel a framed plugin relies on since it can't read the + // hub's `localStorage` — then this origin's own stored token. + const authToken = getStoredAuthToken(options.authToken || connectionMeta.authToken) // Persist a resolved token so one supplied out-of-band — e.g. a host that // bootstraps trust by passing `authToken` (read from its own page URL query) // — survives reconnects. The token is still sent to the server via the WS @@ -450,16 +469,60 @@ export async function getDevframeRpcClient( // @ts-expect-error assign to readonly property context.rpc = rpc - void mode.requestTrust() - - // Magic-link authentication: if the page URL carries a one-time code, exchange - // it and strip it from the URL. The code is single-use and short-lived; the - // resulting bearer token is persisted (never written back to the URL). - // Integrations that drive their own auth UI opt out with `otpParam: false` - // and call `authenticateWithUrlOtp` / `consumeOtpFromUrl` directly. - const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM - if (otpParam) - void authenticateWithUrlOtp(rpc, { param: otpParam }) + + // Whether this document is the top-level, unframed page. Only there can a + // native `prompt()` actually be shown — a framed plugin (hub dock) instead + // waits for a hub-injected/broadcast token to arrive. Accessing + // `window.top` cross-origin throws, which itself means we're framed. + function isTopLevelUnframed(): boolean { + try { + return typeof window !== 'undefined' && window.self === window.top + } + catch { + return false + } + } + + // Last-resort standalone fallback: ask for the one-time code via the + // browser's native `prompt()` (zero UI, so devframe stays headless) and + // re-prompt on a wrong/expired code until the exchange succeeds or the user + // cancels. Cancelling leaves the connection `unauthorized` without nagging. + async function runSimpleAuthPrompt(): Promise { + if (options.simpleAuth === false || !isTopLevelUnframed()) + return + if (typeof globalThis.prompt !== 'function') + return + while (!rpc.isTrusted) { + // eslint-disable-next-line no-alert -- native prompt() is intentional: zero UI keeps devframe headless. + const code = globalThis.prompt('devframe: enter the authentication code shown in your terminal') + // Cancel → stop; leave status `unauthorized`. + if (code == null) + return + const trimmed = code.trim() + if (!trimmed) + continue + if (await rpc.requestTrustWithCode(trimmed)) + return + } + } + + // Drive trust in order: the connect-time handshake (stored/injected token) + // first, then the magic-link OTP (silent — a one-time code on the page URL, + // single-use and short-lived, stripped from the URL and never re-persisted), + // then the native-prompt fallback. Integrations that drive their own auth UI + // opt out of the URL read with `otpParam: false` and of the prompt with + // `simpleAuth: false`. + async function bootstrapAuth(): Promise { + const trusted = await mode.requestTrust() + const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM + // Always consume the URL OTP (so it's stripped) even once trusted; it only + // exchanges when a code is present and we're not yet trusted. + const viaOtp = otpParam ? await authenticateWithUrlOtp(rpc, { param: otpParam }) : false + if (trusted || viaOtp || rpc.isTrusted) + return + await runSimpleAuthPrompt() + } + void bootstrapAuth() // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index a7a2fd0d..1d693e2b 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -129,4 +129,19 @@ export interface ConnectionMeta { * endpoint rather than resolving the path against its own mount. */ baseUrl?: string + /** + * A pre-issued bearer token embedded in the meta so a client trusts the + * server on connect without any prompt or `localStorage` lookup. Only a + * **hub** serving a **per-frame** connection meta populates this — it + * authenticates once at the top level and bakes the resulting token into + * the meta each plugin iframe fetches, so a cross-origin frame (which + * cannot read the hub's `localStorage`) is still pre-authorized. The + * standalone `__connection.json` never carries a token. + * + * > [!WARNING] + * > A token in a fetchable JSON is only as protected as the URL serving + * > it. Emit it exclusively from hub-controlled, per-frame meta — never + * > from a publicly reachable static `__connection.json`. + */ + authToken?: string } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index b6a04208..3b93ec41 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -1,5 +1,6 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' +import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' export type DevframeRuntime = 'cli' | 'build' | 'spa' | 'vite' | 'embedded' @@ -108,14 +109,27 @@ export interface DevframeCliOptions { */ open?: boolean | string /** - * Skip the RPC trust handshake. Set to `false` for trusted - * single-user localhost tools. Default `true`. + * Authentication for the standalone dev server. * - * Forwarded to `startHttpAndWs` as a no-op placeholder until devframe - * ships its own auth layer; `@vitejs/devtools` honors the equivalent - * `devtools.clientAuth` today. + * - `undefined` / `true` — the standalone adapters (`cli` / `spa` / + * served `build`) auto-wire devframe's interactive OTP auth + * (`createInteractiveAuth`): an untrusted client can only reach + * `anonymous:` methods until it exchanges the printed one-time code. + * The adapter prints the code + magic-link banner once the server is + * listening. + * - `false` — no gate, for trusted single-user localhost tools where an + * auth round-trip only gets in the way (the built-in plugins set this). + * The `--no-auth` CLI flag maps here for one-off runs. + * - A {@link DevframeAuthHandler} — a custom handler (e.g. a tuned + * `createInteractiveAuth`, or an entirely different scheme) passed + * straight through to `startHttpAndWs`. + * + * Hosted adapters (`vite`, `embedded`) ignore this and defer to the host's + * auth; `@vitejs/devtools` honors the equivalent `devtools.clientAuth`. + * + * @default true */ - auth?: boolean + auth?: boolean | DevframeAuthHandler /** * Expose a route-based MCP server alongside the dev server, speaking the * MCP Streamable-HTTP transport at `/__mcp` (relative to the base path). diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 72ce9efb..609681ef 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -41,6 +41,7 @@ export interface DevframeRpcClientOptions { baseURL?: string | string[]; authToken?: string; otpParam?: string | false; + simpleAuth?: boolean; wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial;