Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/adapters/cac.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ defineDevframe({
portRange: [7777, 9000], // passed through to get-port-please
random: false, // passed through to get-port-please
host: '127.0.0.1', // default host; --host overrides
open: true, // auto-open the browser on dev start
auth: false, // skip the trust handshake (single-user localhost)
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
configure(cli) { // contribute capability flags/commands
cli.option('--config <file>', 'Custom config file')
.option('--no-files', 'Skip file matching')
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ if (!trusted) {
}
```

`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.

### Authenticating with a one-time code

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:
Expand Down
5 changes: 2 additions & 3 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,7 @@ defineDevframe({
portRange: [9876, 10000], // forwarded to get-port-please
random: false, // forwarded to get-port-please
host: 'localhost', // default host; --host overrides
open: true, // auto-open the browser on dev start
auth: false, // skip the trust handshake (single-user localhost)
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
configure(cli) { // contribute capability flags/commands
cli
.option('--my-flag <value>', 'Tool-specific flag')
Expand All @@ -208,7 +207,7 @@ defineDevframe({
| `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. |
| `random` | `boolean` | Prefer a random open port. |
| `host` | `string` | Default bind host. |
| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. |
| `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. |
| `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. |
| `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCac`'s `configureCli` option so the final tool author always has the last word. |

Expand Down
4 changes: 3 additions & 1 deletion docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Exactly one rule decides what an untrusted connection may call: **a method is re

`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).

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.

## Authentication flow

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

### Magic-link authentication

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

```
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/standalone-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ const devframe = defineDevframe({
distDir,
port: 7777,
portRange: [7777, 9000],
open: true,
auth: false, // single-user localhost — skip the trust handshake
open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated
configure(cli) {
cli
.option('--config <file>', 'Config file path')
Expand Down
61 changes: 61 additions & 0 deletions packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ 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 { open } from 'devframe/utils/open'
import { getPort } from 'get-port-please'
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'

// `--open` is exercised directly (asserting the URL handed to the OS opener)
// rather than actually launching a browser in CI.
vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))

function connectWsClient(host: string, port: number, authToken?: string) {
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
{} as DevframeRpcClientFunctions,
Expand Down Expand Up @@ -323,6 +328,62 @@ describe('adapters/dev', () => {
}
})

it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => {
const devframe = defineDevframe({
id: 'devframe-auth-open',
name: 'Auth Open',
version: '0.0.0',
packageName: 'devframe-test',
homepage: 'https://example.test',
description: 'Test devframe.',
setup: () => {},
})
const host = '127.0.0.1'
const port = await getPort({ port: 19415, host })
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
const mockedOpen = vi.mocked(open)
mockedOpen.mockClear()
const code = getTempAuthCode()
const handle = await createDevServer(devframe, { host, port, openBrowser: true })

try {
expect(mockedOpen).toHaveBeenCalledTimes(1)
const [target] = mockedOpen.mock.calls[0]
expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`)
}
finally {
spy.mockRestore()
await handle.close()
}
})

it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => {
const devframe = defineDevframe({
id: 'devframe-auth-open-off',
name: 'Auth Open Off',
version: '0.0.0',
packageName: 'devframe-test',
homepage: 'https://example.test',
description: 'Test devframe.',
cli: { auth: false },
setup: () => {},
})
const host = '127.0.0.1'
const port = await getPort({ port: 19416, host })
const mockedOpen = vi.mocked(open)
mockedOpen.mockClear()
const handle = await createDevServer(devframe, { host, port, openBrowser: true })

try {
expect(mockedOpen).toHaveBeenCalledTimes(1)
const [target] = mockedOpen.mock.calls[0]
expect(target).toBe(`http://localhost:${port}/`)
}
finally {
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',
Expand Down
9 changes: 7 additions & 2 deletions packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export async function createDevServer(
// 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)
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler)
},
})

Expand Down Expand Up @@ -303,6 +303,7 @@ async function maybeOpenBrowser(
flags: Record<string, unknown>,
origin: string,
override: boolean | string | undefined,
authHandler: DevframeAuthHandler | undefined,
): Promise<void> {
const flagsOpen = flags.open as boolean | string | undefined
const cliOpen = def.cli?.open
Expand All @@ -314,8 +315,12 @@ async function maybeOpenBrowser(
const target = typeof resolved === 'string'
? withBase(resolved, origin)
: origin
// When the server is auth-gated, let the handler embed a one-time
// credential (e.g. the OTP query param) in the opened URL so the tab
// lands already authorized instead of prompting the user.
const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target
try {
await open(target)
await open(authorizedTarget)
}
catch {
// Failing to launch a browser shouldn't break the dev server.
Expand Down
140 changes: 140 additions & 0 deletions packages/devframe/src/client/rpc-auth-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { ConnectionMeta } from 'devframe/types'
import type { DevframeRpcClientMode } from './rpc'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// `getDevframeRpcClient` kicks off the auth bootstrap (`requestTrust`, then
// the URL OTP, then a native-prompt fallback) without awaiting it, so it can
// return the client immediately. The regression this guards is a caller's
// very first `rpc.call(...)` — issued the moment `connectDevframe()` resolves
// — racing that still-in-flight bootstrap: without a gate, the transport
// sends the call over an already-open-but-not-yet-trusted socket and the
// server rejects it with DF0036, even though the exact same call succeeds a
// moment later once the handshake lands.
//
// Exercising this through a real WebSocket would mean hand-rolling birpc's
// wire protocol, so instead this mocks `./rpc-ws` with a controllable fake
// mode — a `requestTrust()` the test resolves on its own schedule, and a
// `call` spy whose invocation timing is exactly what's under test.
const fakeMode = vi.hoisted(() => {
const requestTrustGate = { resolve: undefined as ((value: boolean) => void) | undefined }
const call = vi.fn(async () => 'ok')
const callOptional = vi.fn(async () => 'ok')
const callEvent = vi.fn(async () => {})
return { requestTrustGate, call, callOptional, callEvent }
})

vi.mock('./rpc-ws', () => ({
createWsRpcClientMode: vi.fn((): DevframeRpcClientMode => ({
isTrusted: false,
status: 'connecting',
connectionError: null,
ensureTrusted: async () => true,
requestTrust: () => new Promise<boolean>((resolve) => {
fakeMode.requestTrustGate.resolve = resolve
}),
requestTrustWithToken: async () => true,
requestTrustWithCode: async () => null,
call: fakeMode.call as DevframeRpcClientMode['call'],
callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'],
callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'],
})),
}))

class FakeBroadcastChannel {
onmessage: ((e: any) => void) | null = null
postMessage(): void {}
close(): void {}
}

const connectionMeta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }

describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => {
beforeEach(() => {
fakeMode.requestTrustGate.resolve = undefined
fakeMode.call.mockClear()
fakeMode.callOptional.mockClear()
fakeMode.callEvent.mockClear()
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
vi.stubGlobal('navigator', { userAgent: 'test' })
vi.stubGlobal('location', {
protocol: 'http:',
host: 'localhost:5173',
hostname: 'localhost',
href: 'http://localhost:5173/index.html',
origin: 'http://localhost:5173',
})
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('holds `call` until the in-flight bootstrap settles, instead of sending it early', async () => {
const { getDevframeRpcClient } = await import('./rpc')
const rpc = await getDevframeRpcClient({
connectionMeta,
otpParam: false,
simpleAuth: false,
})

// Fired the instant the client is available — mirrors a component's
// `onMount` calling a trusted method right after `connectDevframe()`.
const pending = rpc.call('test:probe' as any)
await Promise.resolve()
await Promise.resolve()
// The bootstrap's `requestTrust()` hasn't resolved yet — the call must
// not have reached the (mocked) transport.
expect(fakeMode.call).not.toHaveBeenCalled()

// The handshake lands.
fakeMode.requestTrustGate.resolve?.(true)

await expect(pending).resolves.toBe('ok')
expect(fakeMode.call).toHaveBeenCalledTimes(1)
expect(fakeMode.call).toHaveBeenCalledWith('test:probe')
})

it('holds `callOptional` and `callEvent` the same way', async () => {
const { getDevframeRpcClient } = await import('./rpc')
const rpc = await getDevframeRpcClient({
connectionMeta,
otpParam: false,
simpleAuth: false,
})

const pendingOptional = rpc.callOptional('test:optional' as any)
const pendingEvent = rpc.callEvent('test:event' as any)
await Promise.resolve()
await Promise.resolve()
expect(fakeMode.callOptional).not.toHaveBeenCalled()
expect(fakeMode.callEvent).not.toHaveBeenCalled()

fakeMode.requestTrustGate.resolve?.(true)

await expect(pendingOptional).resolves.toBe('ok')
await pendingEvent
expect(fakeMode.callOptional).toHaveBeenCalledTimes(1)
expect(fakeMode.callEvent).toHaveBeenCalledTimes(1)
})

it('stops gating once the first bootstrap attempt has settled', async () => {
const { getDevframeRpcClient } = await import('./rpc')
const rpc = await getDevframeRpcClient({
connectionMeta,
otpParam: false,
simpleAuth: false,
})

fakeMode.requestTrustGate.resolve?.(true)
// Let the bootstrap's own promise chain fully settle before the next
// call — a few microtask ticks cover `await mode.requestTrust()`
// resuming, `bootstrapAuth()` returning, and its `.then()` flipping
// `bootstrapAuthSettled`.
for (let i = 0; i < 5; i++)
await Promise.resolve()

await rpc.call('test:probe' as any)
// Sent straight through — no more waiting once bootstrap is over.
expect(fakeMode.call).toHaveBeenCalledTimes(1)
})
})
39 changes: 35 additions & 4 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,30 @@ export async function getDevframeRpcClient(
}
catch {}

// Gate outbound calls behind the auth bootstrap kicked off below (stored
// auth token, then the URL's magic-link OTP, then — standalone/top-level
// only — a native prompt). Without this, a caller's very first RPC calls
// (fired the moment `connectDevframe()` resolves — e.g. a component's
// `onMount`) race that still-in-flight sequence: the socket is open, so
// the transport happily sends them, and the server rejects them with
// DF0036 because trust hasn't landed yet — even though the exact same call
// would succeed a moment later. `bootstrapAuthPromise` is assigned once
// `bootstrapAuth()` is kicked off further down; these closures read the
// variable at call time, so the gate is live even though it's declared
// before that assignment happens. Once the first bootstrap attempt has
// settled (trusted, refused, or given up), `bootstrapAuthSettled` flips
// for good and every later call skips the gate entirely — this only ever
// holds the first wave of calls.
let bootstrapAuthPromise: Promise<void> | undefined
let bootstrapAuthSettled = false
function gateOnBootstrapAuth<F extends (...args: any[]) => any>(fn: F): F {
return ((...args: any[]) => {
if (bootstrapAuthSettled || !bootstrapAuthPromise)
return fn(...args)
return bootstrapAuthPromise.then(() => fn(...args))
}) as F
}

const rpc: DevframeRpcClient = {
events,
get isTrusted() {
Expand Down Expand Up @@ -440,9 +464,9 @@ export async function getDevframeRpcClient(
catch {}
return true
},
call: mode.call,
callEvent: mode.callEvent,
callOptional: mode.callOptional,
call: gateOnBootstrapAuth(mode.call),
callEvent: gateOnBootstrapAuth(mode.callEvent),
callOptional: gateOnBootstrapAuth(mode.callOptional),
client: clientRpc,
sharedState: undefined!,
streaming: undefined!,
Expand Down Expand Up @@ -522,7 +546,14 @@ export async function getDevframeRpcClient(
return
await runSimpleAuthPrompt()
}
void bootstrapAuth()
// Always resolves (never rejects) regardless of `bootstrapAuth`'s outcome —
// the gate only cares that the first attempt is *over*, not whether it
// succeeded; a rejection here must never leak into unrelated calls waiting
// on `bootstrapAuthPromise`.
bootstrapAuthPromise = bootstrapAuth().then(
() => { bootstrapAuthSettled = true },
() => { bootstrapAuthSettled = true },
)

// Listen for auth updates from other tabs (e.g., the auth page, or another
// tab that just completed a code exchange).
Expand Down
9 changes: 9 additions & 0 deletions packages/devframe/src/node/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,13 @@ export interface DevframeAuthHandler {
* call repeatedly; it only prints once per code.
*/
printBanner: () => void
/**
* Rewrite a URL the CLI is about to open in the browser (`--open`) so the
* tab lands already authenticated — e.g. appending the current OTP as a
* query param. Called with the fully-resolved target URL; return it
* unchanged to opt out. Optional: a handler that doesn't need this (e.g.
* one gated by a pre-shared bearer token instead of an interactive code)
* can omit it and the URL opens as-is.
*/
buildOpenUrl?: (url: string) => string
}
Loading
Loading