diff --git a/alias.ts b/alias.ts index a47ae93..e2b8952 100644 --- a/alias.ts +++ b/alias.ts @@ -18,9 +18,9 @@ export const alias = { 'devframe/node': r('devframe/src/node/index.ts'), 'devframe/constants': r('devframe/src/constants.ts'), 'devframe/utils/colors': r('devframe/src/utils/colors.ts'), + 'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'), 'devframe/utils/events': r('devframe/src/utils/events.ts'), 'devframe/utils/hash': r('devframe/src/utils/hash.ts'), - 'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'), 'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'), 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), 'devframe/utils/open': r('devframe/src/utils/open.ts'), diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 414ad8f..36bd2fa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -28,6 +28,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] { { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, { text: 'Client', link: `${prefix}/guide/client` }, + { text: 'Security', link: `${prefix}/guide/security` }, { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, { text: 'Hub (multi-tool)', link: `${prefix}/guide/hub` }, { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, diff --git a/docs/guide/client.md b/docs/guide/client.md index 8f20a3a..d3f2cc0 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -66,7 +66,7 @@ The client picks a mode automatically from the backend field. Mode-specific code ## Trust & auth (WebSocket mode) -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. +Dev-mode connections become trusted by authenticating. A client that authenticated before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it: ```ts const rpc = await connectDevframe() @@ -75,21 +75,33 @@ const rpc = await connectDevframe() const trusted = await rpc.ensureTrusted() if (!trusted) { - console.warn('Auth denied') + console.warn('Not authenticated yet') } ``` -### Replacing the token +### Authenticating with a one-time code -For tokens supplied from a different source (e.g. copy-pasted from CLI output), swap one in without reloading: +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: ```ts -const ok = await rpc.requestTrustWithToken('another-token') +const ok = await rpc.requestTrustWithCode('047204') +``` + +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. + +To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. + +### Re-using an existing token + +Authenticate with a token obtained elsewhere (e.g. another surface) without reloading: + +```ts +const ok = await rpc.requestTrustWithToken('a1b2c3…') ``` ### Broadcast-channel sync -`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. +`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 authenticates — or an auth page announces a token — every open client trusts it automatically, no reload required. ## Calling functions diff --git a/docs/guide/security.md b/docs/guide/security.md new file mode 100644 index 0000000..1f4c3df --- /dev/null +++ b/docs/guide/security.md @@ -0,0 +1,76 @@ +--- +outline: deep +--- + +# Security + +Devframe tools are secure by default: connections bind to `localhost`, and dev-mode RPC requires a trust handshake before a browser is accepted. This page covers the trust model and the practices that keep a tool safe as it moves beyond a single developer's machine. + +## Trust model + +An RPC handler runs with the full privileges of the process hosting it — filesystem, child processes, network. A trusted connection can call any registered function, so the boundary that matters is *who is allowed to connect*. + +Two postures cover that boundary: + +- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and authentication UI. +- **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction. + +> [!WARNING] +> `auth: false` trusts every connection that can reach the port. Only use it when the surface is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment. + +## 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. + +1. A fresh client connects unauthenticated and calls `devframe:anonymous:auth` with its stored token (empty on first run). The server returns `{ isTrusted: false }`, so the trust gate stays open while the UI prompts for a code. +2. The dev server shows a 6-digit one-time code in the developer's terminal. +3. The developer enters it; the browser calls `requestTrustWithCode(code)` → `devframe:auth:exchange`. +4. The server verifies the code, mints a high-entropy bearer token, records it as trusted, marks the session trusted, and returns the token. +5. The browser persists the token and presents it on reconnect (`devframe:anonymous:auth` → `verifyAuthToken`); sibling tabs receive it over the `devframe-auth` channel and become trusted too. + +The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts — which is what keeps a short code brute-force resistant. Show it only in a trusted channel (the terminal), never over the network. + +The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event. + +### Auth methods + +Devframe owns the wire contract; the host adapter registers the handlers on top of the `devframe/node/auth` primitives (the standalone server registers a noop auto-trust handler when `auth: false`). + +| RPC method | Direction | Shape | +|------------|-----------|-------| +| `devframe:anonymous:auth` | client → server | `{ authToken, ua, origin }` → `{ isTrusted }` — re-authenticate a stored token | +| `devframe:auth:exchange` | client → server | `{ code, ua, origin }` → `{ authToken \| null }` — exchange a one-time code for a token | +| `devframe:auth:revoked` | server → client | event — the connection's token was revoked | + +Node primitives (`devframe/node/auth`): + +| Function | Role | +|----------|------| +| `getTempAuthCode()` / `refreshTempAuthCode()` | read / rotate the current one-time code to display | +| `exchangeTempAuthCode(code, session, { ua, origin }, storage)` | verify a code, mint + store the token, trust the session, return the token (or `null`) | +| `verifyAuthToken(token, session, storage)` | trust a session presenting a known token (reconnect) | +| `buildOtpAuthUrl(origin, code?)` | build a magic-link URL embedding the code | +| `revokeAuthToken(context, storage, token)` | delete a token and disconnect any sessions using it | + +Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate). + +### 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): + +``` +Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456 +``` + +`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. + +Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`. + +## Practices for tools built on devframe + +- **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. +- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. +- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output. +- **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. +- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin. +- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`. diff --git a/docs/helpers/utilities.md b/docs/helpers/utilities.md index 0d27ab9..20071e5 100644 --- a/docs/helpers/utilities.md +++ b/docs/helpers/utilities.md @@ -74,25 +74,27 @@ const wire = structuredCloneStringify(new Map([['a', 1]])) const value = structuredCloneParse>(wire) ``` -### `devframe/utils/human-id` +### `devframe/utils/nanoid` -Generate a human-readable, lowercase, dash-separated random ID. +Tiny URL-safe random ID generator (vendored, no runtime dependency). ```ts -import { humanId } from 'devframe/utils/human-id' +import { nanoid } from 'devframe/utils/nanoid' -humanId() // 'bright-orange-tiger' +nanoid() // 21 chars +nanoid(10) // 10 chars ``` -### `devframe/utils/nanoid` +### `devframe/utils/crypto-token` -Tiny URL-safe random ID generator (vendored, no runtime dependency). +Cryptographically-secure token helpers built on the WebCrypto global, so they run in browsers and Node alike. Use these for bearer credentials and human-typed one-time codes. ```ts -import { nanoid } from 'devframe/utils/nanoid' +import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' -nanoid() // 21 chars -nanoid(10) // 10 chars +randomToken() // 32-char hex, 128 bits of entropy — use as a bearer token +randomDigits(6) // '047204' — uniform, leading zeros preserved +timingSafeEqual(input, secret) // constant-time string comparison ``` ### `devframe/utils/promise` diff --git a/packages/devframe/package.json b/packages/devframe/package.json index d5ee5f7..6481e80 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -40,9 +40,9 @@ "./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs", "./types": "./dist/types/index.mjs", "./utils/colors": "./dist/utils/colors.mjs", + "./utils/crypto-token": "./dist/utils/crypto-token.mjs", "./utils/events": "./dist/utils/events.mjs", "./utils/hash": "./dist/utils/hash.mjs", - "./utils/human-id": "./dist/utils/human-id.mjs", "./utils/launch-editor": "./dist/utils/launch-editor.mjs", "./utils/nanoid": "./dist/utils/nanoid.mjs", "./utils/open": "./dist/utils/open.mjs", diff --git a/packages/devframe/src/client/__tests__/otp.test.ts b/packages/devframe/src/client/__tests__/otp.test.ts new file mode 100644 index 0000000..f2eba6a --- /dev/null +++ b/packages/devframe/src/client/__tests__/otp.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { authenticateWithUrlOtp, consumeOtpFromUrl, readOtpFromUrl } from '../otp' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('otp url helpers', () => { + it('reads the OTP from the page URL query string (default param)', () => { + vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + expect(readOtpFromUrl()).toBe('123456') + }) + + it('supports a custom param name', () => { + vi.stubGlobal('location', { search: '?code=999', href: 'http://localhost:3000/?code=999' }) + expect(readOtpFromUrl('code')).toBe('999') + }) + + it('returns undefined when the param is absent and is safe without location', () => { + vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + expect(readOtpFromUrl()).toBeUndefined() + vi.stubGlobal('location', undefined) + expect(readOtpFromUrl()).toBeUndefined() + expect(() => consumeOtpFromUrl()).not.toThrow() + }) + + it('consume reads then strips the OTP via history.replaceState, keeping other params', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + vi.stubGlobal('history', { state: { a: 1 }, replaceState }) + + expect(consumeOtpFromUrl()).toBe('123456') + expect(replaceState).toHaveBeenCalledTimes(1) + const [state, , href] = replaceState.mock.calls[0] + expect(state).toEqual({ a: 1 }) + expect(href).toBe('http://localhost:3000/?x=1') + }) + + it('does not touch the URL when no OTP is present', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + vi.stubGlobal('history', { state: null, replaceState }) + + expect(consumeOtpFromUrl()).toBeUndefined() + expect(replaceState).not.toHaveBeenCalled() + }) +}) + +describe('authenticateWithUrlOtp', () => { + it('exchanges the OTP via the client and resolves true on success', async () => { + vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('history', { state: null, replaceState: vi.fn() }) + const requestTrustWithCode = vi.fn().mockResolvedValue(true) + + const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + + expect(requestTrustWithCode).toHaveBeenCalledWith('123456') + expect(ok).toBe(true) + }) + + it('returns false (and does not exchange) when no OTP is present', async () => { + vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' }) + const requestTrustWithCode = vi.fn() + + const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + + expect(requestTrustWithCode).not.toHaveBeenCalled() + expect(ok).toBe(false) + }) + + it('skips the exchange but still consumes the OTP when already trusted', async () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('history', { state: null, replaceState }) + const requestTrustWithCode = vi.fn() + + const ok = await authenticateWithUrlOtp({ isTrusted: true, requestTrustWithCode }) + + expect(ok).toBe(true) + expect(requestTrustWithCode).not.toHaveBeenCalled() + expect(replaceState).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 99f65ab..2d5855f 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -1,5 +1,6 @@ import { getDevframeRpcClient } from './rpc' +export * from './otp' export * from './rpc' export * from './rpc-streaming' export * from './scope' diff --git a/packages/devframe/src/client/otp.ts b/packages/devframe/src/client/otp.ts new file mode 100644 index 0000000..ee7092f --- /dev/null +++ b/packages/devframe/src/client/otp.ts @@ -0,0 +1,66 @@ +import type { DevframeRpcClient } from './rpc' +import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' + +// Browser-only helpers for "magic link" authentication: a host prints a URL +// carrying a one-time authentication code (OTP), and the client reads it, +// exchanges it for a token, and removes it from the address bar. Only the +// short-lived, single-use OTP ever rides the URL — never the resulting token. + +/** + * Read a one-time authentication code (OTP) from the current page URL's query + * string, without side effects. Returns `undefined` when the parameter is absent. + */ +export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { + try { + return new URLSearchParams(globalThis.location?.search).get(param) || undefined + } + catch { + return undefined + } +} + +function stripParamFromUrl(param: string): void { + try { + const url = new URL(globalThis.location!.href) + if (!url.searchParams.has(param)) + return + url.searchParams.delete(param) + globalThis.history?.replaceState(globalThis.history.state, '', url.href) + } + catch {} +} + +/** + * Read the one-time code from the page URL and remove it from the address bar + * (and the current history entry), so the single-use code isn't left in the + * URL, browser history, or a `Referer`. Returns the code, or `undefined` when + * absent. + */ +export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { + const code = readOtpFromUrl(param) + if (code) + stripParamFromUrl(param) + return code +} + +/** + * Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and + * exchange it for a token via the client. Resolves `true` when the client is + * authenticated (already trusted, or the exchange succeeded), and `false` when + * no code is present or the exchange failed. + * + * Higher-level integrations (e.g. Vite DevTools) that want to drive their own + * authentication UI can disable `connectDevframe`'s built-in handling with + * `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves. + */ +export async function authenticateWithUrlOtp( + rpc: Pick, + options: { param?: string } = {}, +): Promise { + const code = consumeOtpFromUrl(options.param ?? DEVFRAME_OTP_URL_PARAM) + if (!code) + return false + if (rpc.isTrusted) + return true + return rpc.requestTrustWithCode(code) +} diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts index ecdff34..a3ce402 100644 --- a/packages/devframe/src/client/rpc-static.ts +++ b/packages/devframe/src/client/rpc-static.ts @@ -16,6 +16,8 @@ export async function createStaticRpcClientMode( isTrusted: true, requestTrust: async () => true, requestTrustWithToken: async () => true, + // Static backends are always trusted, so there's nothing to exchange. + requestTrustWithCode: async () => null, ensureTrusted: async () => true, call: (...args: any): any => staticCaller.call( args[0] as string, diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index 54d8ccb..877427b 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -6,7 +6,7 @@ import { promiseWithResolver } from 'devframe/utils/promise' import { parseUA } from 'ua-parser-modern' export interface CreateWsRpcClientModeOptions { - authToken: string + authToken?: string connectionMeta: ConnectionMeta events: EventEmitter clientRpc: DevframeClientRpcHost @@ -69,13 +69,11 @@ export function createWsRpcClientMode( }, }) - let currentAuthToken = authToken - - async function requestTrustWithToken(token: string) { - currentAuthToken = token + let currentAuthToken: string | undefined = authToken + function describeUA(): string { const info = parseUA(navigator.userAgent) - const ua = [ + return [ info.browser.name, info.browser.version, '|', @@ -83,23 +81,52 @@ export function createWsRpcClientMode( info.os.version, info.device.type, ].filter(i => i).join(' ') + } + + async function requestTrustWithToken(token: string) { + currentAuthToken = token const result = await serverRpc.$call('devframe:anonymous:auth', { authToken: token, - ua, + ua: describeUA(), origin: location.origin, }) isTrusted = result.isTrusted - trustedPromise.resolve(isTrusted) + // Only settle the trust gate on success; on failure the client can still + // authenticate via `requestTrustWithCode`, so leave `ensureTrusted` waiting. + if (isTrusted) + trustedPromise.resolve(true) events.emit('rpc:is-trusted:updated', isTrusted) return result.isTrusted } + async function requestTrustWithCode(code: string): Promise { + const result = await serverRpc.$call('devframe:auth:exchange', { + code, + ua: describeUA(), + origin: location.origin, + }) + + const token = result?.authToken ?? null + if (token) { + currentAuthToken = token + isTrusted = true + trustedPromise.resolve(true) + events.emit('rpc:is-trusted:updated', true) + } + return token + } + async function requestTrust() { if (isTrusted) return true - return requestTrustWithToken(currentAuthToken) + // Always announce on connect. The standalone (`auth: false`) noop handler + // auto-trusts regardless of token; the host adapter looks the token up and + // returns `false` for an unauthenticated client (empty/unknown token), which + // then authenticates via `requestTrustWithCode`. The trust gate stays open + // until then. + return requestTrustWithToken(currentAuthToken ?? '') } async function ensureTrusted(timeout = 60_000): Promise { @@ -129,6 +156,7 @@ export function createWsRpcClientMode( }, requestTrust, requestTrustWithToken, + requestTrustWithCode, ensureTrusted, call: (...args: any): any => { return serverRpc.$call( diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 1bb4617..55aa1ab 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -6,10 +6,11 @@ import type { RpcStreamingClientHost } from './rpc-streaming' import type { DevframeScopedClientContext } from './scope' import { DEVFRAME_CONNECTION_META_FILENAME, + DEVFRAME_OTP_URL_PARAM, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' -import { humanId } from 'devframe/utils/human-id' +import { authenticateWithUrlOtp } from './otp' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' @@ -39,6 +40,14 @@ export interface DevframeRpcClientOptions { * The auth token to use for the client */ authToken?: string + /** + * Query-param name on the page URL carrying a one-time authentication code + * (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'`. + */ + otpParam?: string | false wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -77,11 +86,19 @@ export interface DevframeRpcClient { requestTrust: () => Promise /** - * Request trust from the server using a specific auth token. + * Request trust from the server using a previously-issued auth token. * Updates the stored token and re-requests trust without reloading the page. */ requestTrustWithToken: (token: string) => Promise + /** + * Authenticate this client by exchanging a one-time code (shown by the dev + * server) for a node-issued auth token. On success the token is persisted for + * future reconnections and shared with sibling tabs. Resolves `true` when + * authenticated. + */ + requestTrustWithCode: (code: string) => Promise + /** * Call a RPC function on the server */ @@ -134,37 +151,45 @@ export interface DevframeRpcClientMode { ensureTrusted: DevframeRpcClient['ensureTrusted'] requestTrust: DevframeRpcClient['requestTrust'] requestTrustWithToken: DevframeRpcClient['requestTrustWithToken'] + /** + * Exchange a one-time code for a node-issued token. Resolves the minted + * token on success (for the caller to persist), or `null` on failure. + */ + requestTrustWithCode: (code: string) => Promise call: DevframeRpcClient['call'] callEvent: DevframeRpcClient['callEvent'] callOptional: DevframeRpcClient['callOptional'] } -function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { +function getStoredAuthToken(userAuthToken?: string): string | undefined { const getters = [ () => userAuthToken, - () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY), + () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined, () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (parent.window as any)?.[CONNECTION_AUTH_TOKEN_KEY], ] - let value: string | undefined - for (const getter of getters) { try { - value = getter() + const value = getter() if (value) - break + return value } catch {} } - if (!value) - value = humanId() + // No token yet — the client is unauthenticated and must exchange a one-time + // code (see `requestTrustWithCode`) to obtain a node-issued token. + return undefined +} - localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value) - ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value - return value +function persistAuthToken(token: string): void { + try { + localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) + } + catch {} + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token } function findConnectionMetaFromWindows(): ConnectionMeta | undefined { @@ -239,7 +264,13 @@ export async function getDevframeRpcClient( const context: DevframeRpcContext = { rpc: undefined!, } - const authToken = getConnectionAuthTokenFromWindows(options.authToken) + const authToken = getStoredAuthToken(options.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 + // URL query param (`?devframe_auth_token=`) by the transport. + if (authToken) + persistAuthToken(authToken) const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) async function fetchJsonFromBases(path: string): Promise { @@ -299,6 +330,13 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) + // Channel name kept for cross-tab interop with the Vite DevTools auth page. + let authChannel: BroadcastChannel | undefined + try { + authChannel = new BroadcastChannel('devframe-auth') + } + catch {} + const rpc: DevframeRpcClient = { events, get isTrusted() { @@ -309,10 +347,22 @@ export async function getDevframeRpcClient( requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { // Update stored token for future reconnections - localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) - ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token + persistAuthToken(token) return mode.requestTrustWithToken(token) }, + requestTrustWithCode: async (code: string) => { + const token = await mode.requestTrustWithCode(code) + if (!token) + return false + // Persist the node-issued token and share it with sibling tabs so they + // become trusted without re-entering the code. + persistAuthToken(token) + try { + authChannel?.postMessage({ type: 'auth-update', authToken: token }) + } + catch {} + return true + }, call: mode.call, callEvent: mode.callEvent, callOptional: mode.callOptional, @@ -344,17 +394,24 @@ export async function getDevframeRpcClient( context.rpc = rpc void mode.requestTrust() - // Listen for auth updates from other tabs (e.g., auth URL page). - // Channel name kept for cross-tab interop with the Vite DevTools auth page. - try { - const bc = new BroadcastChannel('devframe-auth') - bc.onmessage = (event) => { + // 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 }) + + // Listen for auth updates from other tabs (e.g., the auth page, or another + // tab that just completed a code exchange). + if (authChannel) { + authChannel.onmessage = (event) => { if (event.data?.type === 'auth-update' && event.data.authToken) { rpc.requestTrustWithToken(event.data.authToken) } } } - catch {} return rpc } diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 106fd8e..dacf603 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -15,3 +15,12 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' * `@vitejs/devtools-kit`) injected into remote-UI iframe dock URLs. */ export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' + +/** + * Page-URL query parameter carrying a one-time authentication code (OTP) for + * "magic link" auth. A host can print a link like `/?devframe_otp=`; + * the client reads the code, exchanges it for a token, and strips the parameter + * from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` / + * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). + */ +export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp' diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 346a219..490f64c 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,87 +1,129 @@ import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' -import { humanId } from 'devframe/utils/human-id' +import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' +import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' -export interface PendingAuthRequest { - clientAuthToken: string - session: DevframeNodeRpcSession - ua: string - origin: string - resolve: (result: { isTrusted: boolean }) => void - abortController: AbortController - timeout: ReturnType -} - -let pendingAuth: PendingAuthRequest | null = null -let tempAuthToken: string = generateTempId() +/** Number of decimal digits in a human-typed one-time authentication code. */ +const TEMP_AUTH_CODE_LENGTH = 6 +/** + * How long an authentication code stays valid after it is (re)generated. A + * 6-digit code only has ~20 bits of entropy, so a short lifetime plus the + * attempt cap below are what keep it brute-force resistant. + */ +const TEMP_AUTH_CODE_TTL = 5 * 60_000 +/** Failed attempts allowed against a single code before it is rotated. */ +const TEMP_AUTH_MAX_ATTEMPTS = 5 -function generateTempId(): string { - return humanId() -} +let tempAuthCode: string = generateTempCode() +let tempAuthCodeExpiresAt: number = Date.now() + TEMP_AUTH_CODE_TTL +let tempAuthFailedAttempts = 0 -export function getTempAuthToken(): string { - return tempAuthToken +function generateTempCode(): string { + return randomDigits(TEMP_AUTH_CODE_LENGTH) } -export function refreshTempAuthToken(): string { - tempAuthToken = generateTempId() - return tempAuthToken +/** + * The current one-time authentication code. Display this to the user (e.g. in + * the dev-server terminal) so they can type it into the browser to authenticate. + */ +export function getTempAuthCode(): string { + return tempAuthCode } -export function getPendingAuth(): PendingAuthRequest | null { - return pendingAuth +/** + * Rotate the authentication code, resetting its expiry window and failed-attempt + * counter. Call this when a new authentication flow begins (e.g. when an + * untrusted client starts authenticating) so the displayed code is freshly + * valid for its full TTL. + */ +export function refreshTempAuthCode(): string { + tempAuthCode = generateTempCode() + tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL + tempAuthFailedAttempts = 0 + return tempAuthCode } -export function setPendingAuth(request: PendingAuthRequest | null): void { - pendingAuth = request +/** + * Build a "magic link" authentication URL that embeds a one-time code (OTP) as + * a query parameter. Opening it authenticates the client without typing — print + * it on startup (devframe stays headless, so the host prints its own banner). + * Defaults to the current code; the link is subject to the same TTL. + */ +export function buildOtpAuthUrl(baseUrl: string, code: string = tempAuthCode): string { + const url = new URL(baseUrl) + url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code) + return url.href } /** - * Abort and clean up any existing pending auth request. + * Re-authenticate a connection that presents a previously-issued bearer token. + * Returns `true` and marks the session trusted when the token is known. + * + * Used by the `devframe:anonymous:auth` handler so a client that already + * authenticated (token persisted in the browser) is trusted on reconnect + * without entering the code again. */ -export function abortPendingAuth(): void { - if (pendingAuth) { - pendingAuth.abortController.abort() - clearTimeout(pendingAuth.timeout) - pendingAuth = null - } +export function verifyAuthToken( + token: string, + session: DevframeNodeRpcSession, + storage: SharedState, +): boolean { + if (!token || !storage.value().trusted[token]) + return false + + session.meta.clientAuthToken = token + session.meta.isTrusted = true + return true } /** - * Consume the temp auth ID: verify it matches, trust the pending client, and clean up. - * Returns the client's authToken if successful, null otherwise. + * Exchange a one-time authentication code for a fresh, node-issued bearer token. + * + * On success this mints a high-entropy token, records it in the trusted store, + * marks the calling session trusted, rotates the code, and returns the token + * for the client to persist. Returns `null` on any failure. + * + * Because the code is short and human-typed, verification is hardened against + * brute force: it enforces a time-to-live, compares in constant time, and + * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an + * attacker cannot keep guessing against the same code. */ -export function consumeTempAuthToken( - id: string, +export function exchangeTempAuthCode( + code: string, + session: DevframeNodeRpcSession, + info: { ua: string, origin: string }, storage: SharedState, ): string | null { - if (id !== tempAuthToken || !pendingAuth) { + // Expired code: rotate so a stale code can never be redeemed. + if (Date.now() > tempAuthCodeExpiresAt) { + refreshTempAuthCode() return null } - const { clientAuthToken, session, ua, origin, resolve } = pendingAuth + if (!timingSafeEqual(code, tempAuthCode)) { + tempAuthFailedAttempts += 1 + // Too many wrong guesses — invalidate this code entirely. + if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) + refreshTempAuthCode() + return null + } - // Trust the pending client + // Code is valid — mint a fresh, node-issued bearer token for this client. + const authToken = randomToken() storage.mutate((state) => { - state.trusted[clientAuthToken] = { - authToken: clientAuthToken, - ua, - origin, + state.trusted[authToken] = { + authToken, + ua: info.ua, + origin: info.origin, timestamp: Date.now(), } }) - session.meta.clientAuthToken = clientAuthToken + session.meta.clientAuthToken = authToken session.meta.isTrusted = true - // Resolve the pending auth RPC call - resolve({ isTrusted: true }) - - // Abort terminal prompt and clean up - abortPendingAuth() - - // Generate a new temp ID for next use - refreshTempAuthToken() + // Rotate the code so it can never be replayed. + refreshTempAuthCode() - return clientAuthToken + return authToken } diff --git a/packages/devframe/src/node/hub-internals/context.ts b/packages/devframe/src/node/hub-internals/context.ts index 43506b8..080da08 100644 --- a/packages/devframe/src/node/hub-internals/context.ts +++ b/packages/devframe/src/node/hub-internals/context.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import { humanId } from 'devframe/utils/human-id' +import { randomToken } from 'devframe/utils/crypto-token' import { join } from 'pathe' import { revokeActiveConnectionsForToken, revokeAuthToken } from '../auth/revoke' import { createStorage } from '../storage' @@ -80,7 +80,7 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token), remoteTokens, allocateRemoteToken(dockId, origin, originLock) { - const token = humanId() + const token = randomToken() remoteTokens.set(token, { dockId, origin, originLock }) return token }, diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index d84c52e..037c3d5 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -8,6 +8,35 @@ import { attachWsRpcTransport } from './ws-server' vi.stubGlobal('WebSocket', WebSocket) +describe('ws auth token in URL', () => { + it('appends the auth token as a URL query param, url-encoded, and omits it when absent', () => { + const urls: string[] = [] + class CapturingWS { + constructor(public url: string) { + urls.push(url) + } + + addEventListener() {} + removeEventListener() {} + send() {} + readyState = 0 + } + + try { + vi.stubGlobal('WebSocket', CapturingWS) + createWsRpcChannel({ url: 'ws://127.0.0.1:1234' }) + createWsRpcChannel({ url: 'ws://127.0.0.1:1234', authToken: 'a b/c+d' }) + + expect(urls[0]).toBe('ws://127.0.0.1:1234') + expect(urls[1]).toBe('ws://127.0.0.1:1234?devframe_auth_token=a%20b%2Fc%2Bd') + } + finally { + // Restore the real ws implementation for the connection tests below. + vi.stubGlobal('WebSocket', WebSocket) + } + }) +}) + describe('devframe rpc', () => { it('should work w/ ws transport', async () => { const PORT = 3333 diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 48bbbe8..02d1d34 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -2,6 +2,14 @@ * To be extended */ export interface DevframeRpcClientFunctions { + /** + * Server→client notification that this connection's auth token has been + * revoked. The client drops to untrusted on receipt. Broadcast by + * `revokeActiveConnectionsForToken`. + * + * @internal + */ + 'devframe:auth:revoked': () => Promise /** * Streaming chunk pushed from server to subscribed clients. Wired by * `RpcStreamingHost`; do not register manually. @@ -37,19 +45,29 @@ export interface DevframeRpcClientFunctions { * @internal */ 'devframe:rpc:client-state:patch': (key: string, patches: any[], syncId: string) => Promise - /** - * Server→client notification that the client's auth token was revoked. - * Broadcast by `revokeActiveConnectionsForToken`; do not register manually. - * - * @internal - */ - 'devframe:auth:revoked': () => Promise } /** * To be extended */ export interface DevframeRpcServerFunctions { + /** + * Authenticate a connection with a previously-issued bearer token; resolves + * whether the connection is now trusted. The interactive handler is provided + * by the host adapter (e.g. Vite DevTools); the standalone server registers + * an auto-trust noop when `auth: false`. + * + * @internal + */ + 'devframe:anonymous:auth': (params: { authToken: string, ua: string, origin: string }) => Promise<{ isTrusted: boolean }> + /** + * Exchange a one-time authentication code (shown by the dev server) for a fresh, + * node-issued bearer token, returning the token on success or `null`. The + * handler is provided by the host adapter on top of `exchangeTempAuthCode`. + * + * @internal + */ + 'devframe:auth:exchange': (params: { code: string, ua: string, origin: string }) => Promise<{ authToken: string | null }> /** * Subscribe a client to a shared-state key. Wired by * `RpcSharedStateHost`; do not register manually. @@ -113,14 +131,6 @@ export interface DevframeRpcServerFunctions { * @internal */ 'devframe:streaming:upload-end': (channel: string, id: string, error?: { name: string, message: string }) => Promise - /** - * Anonymous-auth handshake the browser client issues on connect. The - * standalone server registers a noop auto-trust handler when `auth: false`; - * hosted adapters register the real handler. Do not register manually. - * - * @internal - */ - 'devframe:anonymous:auth': (payload: { authToken: string, ua: string, origin: string }) => Promise<{ isTrusted: boolean }> } /** diff --git a/packages/devframe/src/utils/crypto-token.ts b/packages/devframe/src/utils/crypto-token.ts new file mode 100644 index 0000000..6998eae --- /dev/null +++ b/packages/devframe/src/utils/crypto-token.ts @@ -0,0 +1,60 @@ +// Cryptographically-secure token helpers built on the WebCrypto global +// (`globalThis.crypto`), which is present in browsers and Node 19+. Kept free +// of node builtins so it stays runtime-agnostic (see `test/runtime-agnostic.test.ts`) +// and can be shared by browser-side client code and node-side auth code alike. +// +// `getRandomValues` is available in both secure and insecure contexts, unlike +// `crypto.randomUUID`, so it works even when a devtool is reached over plain +// HTTP on a LAN address. + +const HEX = '0123456789abcdef' + +/** + * Generate a high-entropy, URL-safe (hex) random token suitable for use as a + * bearer credential — e.g. the persistent client auth token or an ephemeral + * remote-dock token. Defaults to 16 bytes (128 bits) of entropy. + */ +export function randomToken(byteLength = 16): string { + const bytes = new Uint8Array(byteLength) + globalThis.crypto.getRandomValues(bytes) + let out = '' + for (let i = 0; i < bytes.length; i++) + out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 0x0F] + return out +} + +/** + * Generate a uniformly-distributed string of decimal digits using rejection + * sampling to avoid modulo bias. Intended for short, human-typed one-time + * codes (e.g. a 6-digit authentication code). Leading zeros are preserved. + */ +export function randomDigits(length: number): string { + // Largest multiple of 10 that fits in a byte; reject values at/above it so + // every digit is equally likely. + const limit = 250 + const buf = new Uint8Array(1) + let out = '' + while (out.length < length) { + globalThis.crypto.getRandomValues(buf) + if (buf[0] < limit) + out += String(buf[0] % 10) + } + return out +} + +/** + * Constant-time string equality. Compares every character so the comparison + * time does not depend on the position of the first mismatch, mitigating + * timing side-channels when verifying secrets. + * + * Length is treated as public (it short-circuits on differing lengths), which + * is appropriate for fixed-length codes and tokens. + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) + return false + let mismatch = 0 + for (let i = 0; i < a.length; i++) + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) + return mismatch === 0 +} diff --git a/packages/devframe/src/utils/human-id.ts b/packages/devframe/src/utils/human-id.ts deleted file mode 100644 index 2e3b312..0000000 --- a/packages/devframe/src/utils/human-id.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Vendored from `human-id@4.1.3` (MIT — Copyright (c) 2018 RienNeVaPlus). -// The upstream is shipped as CommonJS, which tsdown wraps with a shim that -// imports `node:module`. Inlining the relevant subset keeps this entry -// runtime-agnostic (see `test/runtime-agnostic.test.ts`). -const adjectives = ['afraid', 'all', 'beige', 'better', 'big', 'blue', 'bold', 'brave', 'breezy', 'bright', 'brown', 'bumpy', 'busy', 'calm', 'chatty', 'chilly', 'chubby', 'clean', 'clear', 'clever', 'cold', 'common', 'cool', 'cozy', 'crisp', 'cuddly', 'curly', 'curvy', 'cute', 'cyan', 'dark', 'deep', 'dirty', 'dry', 'dull', 'eager', 'early', 'easy', 'eight', 'eighty', 'eleven', 'empty', 'every', 'fair', 'famous', 'fancy', 'fast', 'few', 'fiery', 'fifty', 'fine', 'five', 'flat', 'floppy', 'fluffy', 'forty', 'four', 'frank', 'free', 'fresh', 'fruity', 'full', 'funky', 'funny', 'fuzzy', 'gentle', 'giant', 'gold', 'good', 'goofy', 'great', 'green', 'grumpy', 'happy', 'heavy', 'hip', 'honest', 'hot', 'huge', 'humble', 'hungry', 'icy', 'itchy', 'jolly', 'khaki', 'kind', 'large', 'late', 'lazy', 'legal', 'lemon', 'light', 'little', 'long', 'loose', 'loud', 'lovely', 'lucky', 'major', 'many', 'metal', 'mighty', 'modern', 'moody', 'neat', 'new', 'nice', 'nine', 'ninety', 'odd', 'old', 'olive', 'open', 'orange', 'perky', 'petite', 'pink', 'plain', 'plenty', 'polite', 'pretty', 'proud', 'public', 'puny', 'purple', 'quick', 'quiet', 'rare', 'ready', 'real', 'red', 'rich', 'ripe', 'salty', 'seven', 'shaggy', 'shaky', 'sharp', 'shiny', 'short', 'shy', 'silent', 'silly', 'silver', 'six', 'sixty', 'slick', 'slimy', 'slow', 'small', 'smart', 'smooth', 'social', 'soft', 'solid', 'some', 'sour', 'sparkly', 'spicy', 'spotty', 'stale', 'strict', 'strong', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'tangy', 'tasty', 'ten', 'tender', 'thick', 'thin', 'thirty', 'three', 'tidy', 'tiny', 'tired', 'tough', 'tricky', 'true', 'twelve', 'twenty', 'two', 'upset', 'vast', 'violet', 'wacky', 'warm', 'wet', 'whole', 'wicked', 'wide', 'wild', 'wise', 'witty', 'yellow', 'young', 'yummy'] - -const nouns = ['actors', 'ads', 'adults', 'aliens', 'animals', 'ants', 'apes', 'apples', 'areas', 'baboons', 'badgers', 'bags', 'balloons', 'bananas', 'banks', 'bars', 'baths', 'bats', 'beans', 'bears', 'beds', 'beers', 'bees', 'berries', 'bikes', 'birds', 'boats', 'bobcats', 'books', 'bottles', 'boxes', 'breads', 'brooms', 'buckets', 'bugs', 'buses', 'bushes', 'buttons', 'camels', 'cameras', 'candies', 'candles', 'canyons', 'carpets', 'carrots', 'cars', 'cases', 'cats', 'chairs', 'chefs', 'chicken', 'cities', 'clocks', 'cloths', 'clouds', 'clowns', 'clubs', 'coats', 'cobras', 'coins', 'colts', 'comics', 'cooks', 'corners', 'cougars', 'cows', 'crabs', 'crews', 'cups', 'cycles', 'dancers', 'days', 'deer', 'deserts', 'dingos', 'dodos', 'dogs', 'dolls', 'donkeys', 'donuts', 'doodles', 'doors', 'dots', 'dragons', 'drinks', 'dryers', 'ducks', 'eagles', 'ears', 'eels', 'eggs', 'emus', 'ends', 'experts', 'eyes', 'facts', 'falcons', 'fans', 'feet', 'files', 'flies', 'flowers', 'forks', 'foxes', 'friends', 'frogs', 'games', 'garlics', 'geckos', 'geese', 'ghosts', 'gifts', 'glasses', 'goats', 'grapes', 'groups', 'guests', 'hairs', 'hands', 'hats', 'heads', 'hoops', 'hornets', 'horses', 'hotels', 'hounds', 'houses', 'humans', 'icons', 'ideas', 'impalas', 'insects', 'islands', 'items', 'jars', 'jeans', 'jobs', 'jokes', 'keys', 'kids', 'kings', 'kiwis', 'knives', 'lamps', 'lands', 'laws', 'lemons', 'lies', 'lights', 'lilies', 'lines', 'lions', 'lizards', 'llamas', 'loops', 'mails', 'mammals', 'mangos', 'maps', 'masks', 'meals', 'melons', 'memes', 'meteors', 'mice', 'mirrors', 'moles', 'moments', 'monkeys', 'months', 'moons', 'moose', 'mugs', 'nails', 'needles', 'news', 'nights', 'numbers', 'olives', 'onions', 'oranges', 'otters', 'owls', 'pandas', 'pans', 'pants', 'papayas', 'papers', 'parents', 'parks', 'parrots', 'parts', 'paths', 'paws', 'peaches', 'pears', 'peas', 'pens', 'pets', 'phones', 'pianos', 'pigs', 'pillows', 'places', 'planes', 'planets', 'plants', 'plums', 'poems', 'poets', 'points', 'pots', 'pugs', 'pumas', 'queens', 'rabbits', 'radios', 'rats', 'ravens', 'readers', 'regions', 'results', 'rice', 'rings', 'rivers', 'rockets', 'rocks', 'rooms', 'roses', 'rules', 'sails', 'schools', 'seals', 'seas', 'sheep', 'shirts', 'shoes', 'showers', 'shrimps', 'sides', 'signs', 'singers', 'sites', 'sloths', 'snails', 'snakes', 'socks', 'spiders', 'spies', 'spoons', 'squids', 'stamps', 'stars', 'states', 'steaks', 'streets', 'suits', 'suns', 'swans', 'symbols', 'tables', 'taxes', 'taxis', 'teams', 'teeth', 'terms', 'things', 'ties', 'tigers', 'times', 'tips', 'tires', 'toes', 'tools', 'towns', 'toys', 'trains', 'trams', 'trees', 'turkeys', 'turtles', 'vans', 'views', 'walls', 'wasps', 'waves', 'ways', 'webs', 'weeks', 'windows', 'wings', 'wolves', 'wombats', 'words', 'worlds', 'worms', 'yaks', 'years', 'zebras', 'zoos'] - -const verbs = ['accept', 'act', 'add', 'admire', 'agree', 'allow', 'appear', 'argue', 'arrive', 'ask', 'attack', 'attend', 'bake', 'bathe', 'battle', 'beam', 'beg', 'begin', 'behave', 'bet', 'boil', 'bow', 'brake', 'brush', 'build', 'burn', 'buy', 'call', 'camp', 'care', 'carry', 'change', 'cheat', 'check', 'cheer', 'chew', 'clap', 'clean', 'cough', 'count', 'cover', 'crash', 'create', 'cross', 'cry', 'cut', 'dance', 'decide', 'deny', 'design', 'dig', 'divide', 'do', 'double', 'doubt', 'draw', 'dream', 'dress', 'drive', 'drop', 'drum', 'eat', 'end', 'enjoy', 'enter', 'exist', 'fail', 'fall', 'feel', 'fetch', 'film', 'find', 'fix', 'flash', 'float', 'flow', 'fly', 'fold', 'follow', 'fry', 'give', 'glow', 'go', 'grab', 'greet', 'grin', 'grow', 'guess', 'hammer', 'hang', 'happen', 'heal', 'hear', 'help', 'hide', 'hope', 'hug', 'hunt', 'invent', 'invite', 'itch', 'jam', 'jog', 'join', 'joke', 'judge', 'juggle', 'jump', 'kick', 'kiss', 'kneel', 'knock', 'know', 'laugh', 'lay', 'lead', 'learn', 'leave', 'lick', 'lie', 'like', 'listen', 'live', 'look', 'lose', 'love', 'make', 'march', 'marry', 'mate', 'matter', 'melt', 'mix', 'move', 'nail', 'notice', 'obey', 'occur', 'open', 'own', 'pay', 'peel', 'pick', 'play', 'poke', 'post', 'press', 'prove', 'pull', 'pump', 'punch', 'push', 'raise', 'read', 'refuse', 'relate', 'relax', 'remain', 'repair', 'repeat', 'reply', 'report', 'rescue', 'rest', 'retire', 'return', 'rhyme', 'ring', 'roll', 'rule', 'run', 'rush', 'say', 'scream', 'search', 'see', 'sell', 'send', 'serve', 'shake', 'share', 'shave', 'shine', 'shop', 'shout', 'show', 'sin', 'sing', 'sink', 'sip', 'sit', 'sleep', 'slide', 'smash', 'smell', 'smile', 'smoke', 'sneeze', 'sniff', 'sort', 'speak', 'spend', 'stand', 'stare', 'start', 'stay', 'stick', 'stop', 'strive', 'study', 'swim', 'switch', 'take', 'talk', 'tan', 'tap', 'taste', 'teach', 'tease', 'tell', 'thank', 'think', 'throw', 'tickle', 'tie', 'trade', 'train', 'travel', 'try', 'turn', 'type', 'unite', 'vanish', 'visit', 'wait', 'walk', 'warn', 'wash', 'watch', 'wave', 'wear', 'win', 'wink', 'wish', 'wonder', 'work', 'worry', 'write', 'yawn', 'yell'] - -function pick(arr: readonly string[]): string { - return arr[(Math.random() * arr.length) | 0] -} - -/** - * Generate a human-readable, lowercase, dash-separated random ID - * (e.g. `bright-orange-tigers-jump`). - */ -export function humanId(): string { - return `${pick(adjectives)}-${pick(nouns)}-${pick(verbs)}` -} diff --git a/packages/devframe/test/runtime-agnostic.test.ts b/packages/devframe/test/runtime-agnostic.test.ts index b767aaf..40b7d85 100644 --- a/packages/devframe/test/runtime-agnostic.test.ts +++ b/packages/devframe/test/runtime-agnostic.test.ts @@ -10,9 +10,9 @@ import { describe, expect, it } from 'vitest' const AGNOSTIC_ENTRIES = [ 'client/index.mjs', 'utils/colors.mjs', + 'utils/crypto-token.mjs', 'utils/events.mjs', 'utils/hash.mjs', - 'utils/human-id.mjs', 'utils/nanoid.mjs', 'utils/promise.mjs', 'utils/scope.mjs', diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 51998ad..56e363b 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -59,9 +59,9 @@ const deps = { const clientEntries = { 'client/index': 'src/client/index.ts', 'utils/colors': 'src/utils/colors.ts', + 'utils/crypto-token': 'src/utils/crypto-token.ts', 'utils/events': 'src/utils/events.ts', 'utils/hash': 'src/utils/hash.ts', - 'utils/human-id': 'src/utils/human-id.ts', 'utils/nanoid': 'src/utils/nanoid.ts', 'utils/promise': 'src/utils/promise.ts', 'utils/scope': 'src/utils/scope.ts', @@ -129,9 +129,9 @@ export default defineConfig([ entries: [ resolve(distDir, 'client/index.mjs'), resolve(distDir, 'utils/colors.mjs'), + resolve(distDir, 'utils/crypto-token.mjs'), resolve(distDir, 'utils/events.mjs'), resolve(distDir, 'utils/hash.mjs'), - resolve(distDir, 'utils/human-id.mjs'), resolve(distDir, 'utils/nanoid.mjs'), resolve(distDir, 'utils/promise.mjs'), resolve(distDir, 'utils/scope.mjs'), diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 6939c5d..14a6738 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -552,8 +552,8 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are | `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor (optional `editor` arg) | | `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash — cache keys, dedup | | `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map` / `Set` / `Date` / `BigInt` / cycles | -| `humanId` from `devframe/utils/human-id` | `human-id` | Human-readable IDs (`bright-orange-tiger`) | | `nanoid` from `devframe/utils/nanoid` | (vendored) | URL-safe random IDs | +| `randomToken` / `randomDigits` / `timingSafeEqual` from `devframe/utils/crypto-token` | (native WebCrypto) | CSPRNG bearer tokens, one-time codes, constant-time compare | | `promiseWithResolver` from `devframe/utils/promise` | — | Externally-controlled `Promise` | | `createEventEmitter` from `devframe/utils/events` | — | Typed event bus | | `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container (see `ctx.rpc.sharedState`) | @@ -562,6 +562,20 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers` RPC recipe — it wires the two utilities into named RPC functions ready to register. +## Security (secure by default) + +RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect. Keep that boundary tight: + +- **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI. +- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. +- **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. +- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. +- **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. +- **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. +- **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. + +See [Security](https://devfra.me/security) for the full reference. + ## Testing - Unit-test host classes with fake contexts. @@ -582,6 +596,7 @@ Devframe-level pages (one-tool, portable surface): - [Structured Diagnostics](https://devfra.me/diagnostics) — coded errors via `ctx.diagnostics`, register custom codes - [Utilities](https://devfra.me/utilities) — bundled `devframe/utils/*` helpers (colors, hash, launchEditor, structured-clone, …) - [Client](https://devfra.me/client) — auth handshake, modes, discovery +- [Security](https://devfra.me/security) — trust model, authentication, secure-by-default practices - [Agent-Native](https://devfra.me/agent-native) — agent field, tools/resources, MCP + Claude Desktop Host-specific extras (when mounting into Vite DevTools — other hosts have their own equivalents): diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 663e769..2a5f3a1 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -9,6 +9,7 @@ export interface DevframeRpcClient { ensureTrusted: (_?: number) => Promise; requestTrust: () => Promise; requestTrustWithToken: (_: string) => Promise; + requestTrustWithCode: (_: string) => Promise; call: DevframeRpcClientCall; callEvent: DevframeRpcClientCallEvent; callOptional: DevframeRpcClientCallOptional; @@ -26,6 +27,7 @@ export interface DevframeRpcClientMode { ensureTrusted: DevframeRpcClient['ensureTrusted']; requestTrust: DevframeRpcClient['requestTrust']; requestTrustWithToken: DevframeRpcClient['requestTrustWithToken']; + requestTrustWithCode: (_: string) => Promise; call: DevframeRpcClient['call']; callEvent: DevframeRpcClient['callEvent']; callOptional: DevframeRpcClient['callOptional']; @@ -34,6 +36,7 @@ export interface DevframeRpcClientOptions { connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string; + otpParam?: string | false; wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial; @@ -96,10 +99,15 @@ export type DevframeRpcClientCallOptional = BirpcReturn, _?: { + param?: string; +}): Promise; +export declare function consumeOtpFromUrl(_?: string): string | undefined; export declare function createClientSettings = Record>(_: DevframeRpcClient, _: string): DevframeSettings; export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; +export declare function readOtpFromUrl(_?: string): string | undefined; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 465aa8d..58e177d 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -2,10 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/client` */ // #region Functions +export async function authenticateWithUrlOtp(_, _) {} +export function consumeOtpFromUrl(_) {} export function createClientSettings(_, _) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} export async function getDevframeRpcClient(_) {} +export function readOtpFromUrl(_) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index de0b4fc..b6cf894 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -8,6 +8,7 @@ export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; export declare const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID: string; export declare const DEVFRAME_MOUNT_PATH: string; export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; +export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; export declare const REMOTE_CONNECTION_KEY: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 2146d20..59c320f 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -8,6 +8,7 @@ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ export var DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID /* const */ export var DEVFRAME_MOUNT_PATH /* const */ export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ +export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ export var REMOTE_CONNECTION_KEY /* const */ diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index 86bb867..bfaabcb 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -1,27 +1,15 @@ /** * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ -// #region Interfaces -export interface PendingAuthRequest { - clientAuthToken: string; - session: DevframeNodeRpcSession; +// #region Functions +export declare function buildOtpAuthUrl(_: string, _?: string): string; +export declare function exchangeTempAuthCode(_: string, _: DevframeNodeRpcSession, _: { ua: string; origin: string; - resolve: (_: { - isTrusted: boolean; - }) => void; - abortController: AbortController; - timeout: ReturnType; -} -// #endregion - -// #region Functions -export declare function abortPendingAuth(): void; -export declare function consumeTempAuthToken(_: string, _: SharedState): string | null; -export declare function getPendingAuth(): PendingAuthRequest | null; -export declare function getTempAuthToken(): string; -export declare function refreshTempAuthToken(): string; +}, _: SharedState): string | null; +export declare function getTempAuthCode(): string; +export declare function refreshTempAuthCode(): string; export declare function revokeActiveConnectionsForToken(_: DevframeNodeContext, _: string): Promise; export declare function revokeAuthToken(_: DevframeNodeContext, _: SharedState, _: string): Promise; -export declare function setPendingAuth(_: PendingAuthRequest | null): void; +export declare function verifyAuthToken(_: string, _: DevframeNodeRpcSession, _: SharedState): boolean; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js index fca4b00..7a1bbb2 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -2,12 +2,11 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export function abortPendingAuth() {} -export function consumeTempAuthToken(_, _) {} -export function getPendingAuth() {} -export function getTempAuthToken() {} -export function refreshTempAuthToken() {} -export function setPendingAuth(_) {} +export function buildOtpAuthUrl(_, _) {} +export function exchangeTempAuthCode(_, _, _, _) {} +export function getTempAuthCode() {} +export function refreshTempAuthCode() {} +export function verifyAuthToken(_, _, _) {} // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts new file mode 100644 index 0000000..1a59254 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/crypto-token` + */ +// #region Functions +export declare function randomDigits(_: number): string; +export declare function randomToken(_?: number): string; +export declare function timingSafeEqual(_: string, _: string): boolean; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js new file mode 100644 index 0000000..7c982b7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/crypto-token` + */ +// #region Functions +export function randomDigits(_) {} +export function randomToken(_) {} +export function timingSafeEqual(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts deleted file mode 100644 index 3e2bb5e..0000000 --- a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` - */ -// #region Functions -export declare function humanId(): string; -// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js deleted file mode 100644 index 83b3df5..0000000 --- a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` - */ -// #region Functions -export function humanId() {} -// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 0734e82..5b6156a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,15 +43,15 @@ "devframe/utils/colors": [ "./packages/devframe/src/utils/colors.ts" ], + "devframe/utils/crypto-token": [ + "./packages/devframe/src/utils/crypto-token.ts" + ], "devframe/utils/events": [ "./packages/devframe/src/utils/events.ts" ], "devframe/utils/hash": [ "./packages/devframe/src/utils/hash.ts" ], - "devframe/utils/human-id": [ - "./packages/devframe/src/utils/human-id.ts" - ], "devframe/utils/launch-editor": [ "./packages/devframe/src/utils/launch-editor.ts" ],