diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..29acee663 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# pnpm patch files must stay LF: a CRLF checkout (Windows autocrlf) breaks +# pnpm's patch parser with ERR_PNPM_INVALID_PATCH. +*.patch text eol=lf diff --git a/docs/content/1.guide/11.client.md b/docs/content/1.guide/11.client.md index f38a7558c..c26e6a53e 100644 --- a/docs/content/1.guide/11.client.md +++ b/docs/content/1.guide/11.client.md @@ -87,9 +87,11 @@ if (!trusted) { ### Authenticating with a one-time code -The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts); `requestTrustWithCode` exchanges it for a persisted node-issued token shared across sibling tabs: +The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts) when an untrusted RPC client asks for one: call `requestAuthCode()` when your auth UI shows, passing `{ reissue: true }` from a "re-issue" button to rotate the code first. `requestTrustWithCode` then exchanges it for a persisted node-issued token shared across sibling tabs: ```ts +await rpc.requestAuthCode() +// … the developer reads the code from the terminal … const ok = await rpc.requestTrustWithCode('047204') ``` diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index 1bf89ff5d..68e7f84a7 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -19,14 +19,14 @@ An RPC handler runs with the full privileges of its Node process (filesystem, ch ## The pre-trust gate -One rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`); only the two handshake methods below qualify. +One rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`); only the handshake and code-request methods below qualify. The RPC server binding enforces this: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)`. Every other call from an untrusted session throws [`DF0036`](/errors/DF0036). `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold calls issued during the first handshake and release them once it settles. ## Authentication flow 1. A fresh RPC client calls `anonymous:devframe:auth` with its stored token (empty on first run); the server returns `{ isTrusted: false }` and the UI prompts for a code. -2. The dev server shows a 6-digit code in the terminal (`auth.printBanner()` once listening). +2. The auth UI requests a code (`rpc.requestAuthCode()`, sent automatically when the built-in notice view first shows, or by its "re-issue" button with `{ reissue: true }` to rotate the code first); the dev server prints the 6-digit code, its expiry, and the requesting browser in the terminal. An already-authorized page never triggers a print. 3. The developer enters it; the browser calls `requestTrustWithCode(code)`. 4. The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it. 5. The browser persists the token and presents it on reconnect (or via a `?devframe_auth_token=` query param the connect-time hook checks first); sibling tabs receive it over the `devframe-auth` channel and become trusted. @@ -51,11 +51,11 @@ Pass `clientAuthTokens` for CI/shared machines to skip the prompt, or a custom ` ### Auth methods -The two `anonymous:`-prefixed handshake methods re-authenticate a stored token (`anonymous:devframe:auth`) and exchange a one-time code for a token (`anonymous:devframe:auth:exchange`); `devframe:auth:revoke` self-revokes, and the `devframe:auth:revoked` event drops affected RPC clients to untrusted. Wire shapes are in the [Node-Side API reference](/references/node-api#auth-methods). +The `anonymous:`-prefixed methods re-authenticate a stored token (`anonymous:devframe:auth`), exchange a one-time code for a token (`anonymous:devframe:auth:exchange`), and ask the server to print its code banner (`anonymous:devframe:auth:request-code`); `devframe:auth:revoke` self-revokes, and the `devframe:auth:revoked` event drops affected RPC clients to untrusted. Wire shapes are in the [Node-Side API reference](/references/node-api#auth-methods). Node primitives in `devframe/node/auth` (`getTempAuthCode` / `refreshTempAuthCode`, `exchangeTempAuthCode`, `verifyAuthToken`, `buildOtpAuthUrl`, and `revokeAuthToken`) implement the same flow for a host framework wiring its own gate; signatures are in the [reference](/references/node-api#node-auth-primitives). -RPC client methods (`devframe/client`): `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate). +RPC client methods (`devframe/client`): `requestAuthCode(options?)` (print the code banner; `{ reissue: true }` rotates the code first), `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate). ### Magic-link authentication diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index 4150487c7..ff625b6e3 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -129,7 +129,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so ## Auth -The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known, whether from the `origin` option or derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. +The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner when an untrusted browser client asks for a code (`rpc.requestAuthCode()`); an already-authorized page triggers no print. The magic link's origin comes from the `origin` option, or is derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. ## Relation to the other adapters diff --git a/docs/content/8.references/10.interactive-auth.md b/docs/content/8.references/10.interactive-auth.md index b20353f02..8ae7d25ed 100644 --- a/docs/content/8.references/10.interactive-auth.md +++ b/docs/content/8.references/10.interactive-auth.md @@ -30,7 +30,7 @@ As `auth` it wires `rpcFunctions`, `authorize`, and `onConnect`; see [Security]( | Option | Default | Purpose | |--------|---------|---------| | `clientAuthTokens` | `undefined` | Pre-shared bearer tokens, always trusted. | -| `banner` | a small boxed console message | Called with `{ code, url }`; prints via `printBanner()`. | +| `banner` | a small boxed console message | Called with `{ code, url, expireAt, requester? }` (`requester` is the asking browser client's `{ ua, origin }`, present on client-requested prints); prints via `printBanner()`. | | `onTrusted` | `undefined` | Called with `{ session, authToken }` (the trust session and its token) once a code exchange succeeds, so a host framework rendering its own banner can retract it. | | `serverUrl` | `context.host.resolveOrigin()` | Magic-link base URL. | @@ -38,10 +38,10 @@ Returns a `DevframeAuthHandler`: | Field | Purpose | |-------|---------| -| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (handshake), `devframe:auth:revoke` (self-revoke). | +| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (handshake), `anonymous:devframe:auth:request-code` (client-requested banner print, `reissue: true` rotates the code first), `devframe:auth:revoke` (self-revoke). | | `authorize(methodName, session)` | Resolver gate: allows `anonymous:` methods, else requires `session.meta.isTrusted`. | | `onConnect(peer, session)` | Connect-time trust from a bearer on the WS upgrade URL (`?devframe_auth_token=`). | -| `printBanner()` | Prints the code + magic-link URL. | +| `printBanner()` | Prints the code + magic-link URL, at most once per code. | ## Using the pieces directly @@ -60,6 +60,6 @@ if (!auth.authorize(methodName, session)) auth.onConnect(peer, session) ``` -An exchange rotates the code and prints the new one, and `onTrusted` fires after that, so a host framework retracting a sticky notice drops that follow-up too and calls `auth.printBanner()` when it next wants a code on screen. +The banner prints on demand: an untrusted browser client requests it over `anonymous:devframe:auth:request-code` (the RPC client's `requestAuthCode()`, sent when an auth UI first shows or its "re-issue" action runs), or the host calls `auth.printBanner()` itself. An exchange rotates the code silently, and `onTrusted` fires so a host framework rendering a sticky notice can retract it. Auth storage is internal, not `devframe/node/hub-internals`. diff --git a/docs/content/8.references/4.node-api.md b/docs/content/8.references/4.node-api.md index ab61b81f0..cdb6bf036 100644 --- a/docs/content/8.references/4.node-api.md +++ b/docs/content/8.references/4.node-api.md @@ -180,6 +180,7 @@ The wire-level RPC methods of the trust handshake: [Security](/guide/security#au |------------|-----------|-------| | `anonymous:devframe:auth` | client → server | `{ authToken, ua, origin }` → `{ isTrusted }`: re-authenticate a stored token | | `anonymous:devframe:auth:exchange` | client → server | `{ code, ua, origin }` → `{ authToken \| null }`: exchange a code for a token | +| `anonymous:devframe:auth:request-code` | client → server | `{ ua, origin, reissue? }` → print the code banner in the server terminal (`reissue: true` rotates the code first) | | `devframe:auth:revoke` | client → server | self-revoke the caller's own token | | `devframe:auth:revoked` | server → client | event: token revoked | diff --git a/examples/custom-hub-next/src/client/app/page.tsx b/examples/custom-hub-next/src/client/app/page.tsx index 5b62a85e2..334248917 100644 --- a/examples/custom-hub-next/src/client/app/page.tsx +++ b/examples/custom-hub-next/src/client/app/page.tsx @@ -264,7 +264,10 @@ function AuthOverlay({ rpc }: { rpc: DevframeRpcClient }) { useEffect(() => { inputRef.current?.focus() - }, []) + // The server prints its code banner on request; ask once when this + // overlay first shows (an already-authorized page never mounts it). + void rpc.requestAuthCode().catch(() => {}) + }, [rpc]) async function submit(event: FormEvent) { event.preventDefault() diff --git a/examples/custom-hub-vite/src/client/main.ts b/examples/custom-hub-vite/src/client/main.ts index 925fcca82..0ffc08d44 100644 --- a/examples/custom-hub-vite/src/client/main.ts +++ b/examples/custom-hub-vite/src/client/main.ts @@ -309,6 +309,9 @@ function createAuthOverlay( return overlay.hidden = false setStatus('Waiting for authorization…') + // The server prints its code banner on request; ask once when this + // overlay first shows (an already-authorized page never reveals it). + void rpc.requestAuthCode().catch(() => {}) input.focus() }, remove: () => overlay.remove(), diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 3155c3b10..56bac6b27 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -103,25 +103,29 @@ describe('adapters/handler', () => { try { await devtools.ready - // The banner waits for the public origin: unknown until a request - // arrives, then printed exactly once (the magic link points at the - // origin the handler is actually mounted on). - expect(spy).not.toHaveBeenCalled() - await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json')) - expect(spy).toHaveBeenCalledTimes(1) - expect(String(spy.mock.calls[0])).toContain('http://localhost:4321') + // The banner is on demand: a plain request derives the public origin + // but prints nothing, even for an already-authorized page. await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json')) - expect(spy).toHaveBeenCalledTimes(1) + expect(spy).not.toHaveBeenCalled() const client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`) 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() + // An untrusted client requests the code (the auth view's mount call): + // printed once per code, with the magic link on the derived origin. + await client.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost' }) + await client.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost' }) + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost:4321') + 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') + // The exchange rotates the code without printing the new one. + expect(spy).toHaveBeenCalledTimes(1) client.$close() } finally { @@ -426,22 +430,32 @@ describe('adapters/handler', () => { }) // The auth-link origin is derived from the served request's URL (the fetch - // handler ignores the `Host` header; that path is `nodeMiddleware`'s), so - // each case just points a request at the origin under test and inspects the - // one-time banner (`console.log`). + // handler ignores the `Host` header), so each case points a request at the + // origin under test, then requests a banner over RPC (`reissue` rotates the + // code past the per-code dedupe) and inspects the printed link. async function withBannerSpy( id: string, extra: Partial[1]>, - run: (devtools: ReturnType, spy: ReturnType) => Promise, + run: ( + devtools: ReturnType, + spy: ReturnType, + requestBanner: () => Promise, + ) => Promise, ): Promise { const wsPort = await getPort({ host: '127.0.0.1' }) const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra }) + let client: ReturnType | undefined try { await devtools.ready - await run(devtools, spy) + client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`) + const requestBanner = async (): Promise => { + await client!.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost', reissue: true }) + } + await run(devtools, spy, requestBanner) } finally { + client?.$close() spy.mockRestore() await devtools.close() } @@ -450,47 +464,54 @@ describe('adapters/handler', () => { devtools.handler(new Request(`${origin}/__connection.json`)) it('a hostile first request never becomes the OTP-link origin; a later loopback one does', () => - withBannerSpy('h-poison', {}, async (devtools, spy) => { - // A forged non-loopback origin is not adopted and prints nothing. + withBannerSpy('h-poison', {}, async (devtools, spy, requestBanner) => { + // A forged non-loopback origin is not adopted: a banner requested now + // falls back to the loopback default, never the forged authority. await hit(devtools, 'http://evil.example.com/__h-poison') - expect(spy).not.toHaveBeenCalled() - // A later loopback origin is adopted and prints exactly one OTP link - // (the credential rides the fragment); the reject never locked it out. - await hit(devtools, 'http://localhost:4321/__h-poison') + await requestBanner() expect(spy).toHaveBeenCalledTimes(1) - expect(String(spy.mock.calls[0])).toContain('http://localhost:4321/#devframe_otp=') expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + // A later loopback origin is adopted and the OTP link points at it + // (the credential rides the fragment); the reject never locked it out. + await hit(devtools, 'http://localhost:4321/__h-poison') + await requestBanner() + expect(String(spy.mock.calls[1])).toContain('http://localhost:4321/#devframe_otp=') // First-valid origin is pinned: a second loopback request doesn't move it. await hit(devtools, 'http://127.0.0.1:9999/__h-poison') - expect(spy).toHaveBeenCalledTimes(1) + await requestBanner() + expect(String(spy.mock.calls[2])).toContain('http://localhost:4321/#') })) it('adopts an exactly allow-listed non-loopback origin, but rejects a near-match', () => - withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy) => { - // Prefix/suffix near-matches of the allow-list entry are never adopted. + withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy, requestBanner) => { + // Prefix/suffix near-matches of the allow-list entry are never adopted; + // the link stays on the loopback fallback. await hit(devtools, 'https://tools.example.com.evil.com/__h-allow') await hit(devtools, 'https://evil.tools.example.com/__h-allow') - expect(spy).not.toHaveBeenCalled() + await requestBanner() + expect(String(spy.mock.calls[0])).toContain('http://localhost/#') + expect(String(spy.mock.calls[0])).not.toContain('evil') // The exact allow-listed origin is. await hit(devtools, 'https://tools.example.com/__h-allow') - expect(spy).toHaveBeenCalledTimes(1) - expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#') + await requestBanner() + expect(String(spy.mock.calls[1])).toContain('https://tools.example.com/#') })) it('an explicit origin wins over any request', () => - withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy) => { - // Pinned: the banner points at it before any request, and a forged - // request can't move it. - expect(spy).toHaveBeenCalledTimes(1) + withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy, requestBanner) => { + // Pinned: the banner points at it, and a forged request can't move it. + await requestBanner() expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') await hit(devtools, 'http://evil.example.com/__h-pinned') - expect(spy).toHaveBeenCalledTimes(1) - expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + await requestBanner() + expect(String(spy.mock.calls[1])).toContain('https://pinned.example.com/#') + expect(String(spy.mock.calls[1])).not.toContain('evil.example.com') })) it('canonicalizes an adopted origin, dropping the default port', () => - withBannerSpy('h-canon', {}, async (devtools, spy) => { + withBannerSpy('h-canon', {}, async (devtools, spy, requestBanner) => { await hit(devtools, 'http://localhost:80/__h-canon') + await requestBanner() expect(spy).toHaveBeenCalledTimes(1) expect(String(spy.mock.calls[0])).toContain('http://localhost/#') expect(String(spy.mock.calls[0])).not.toContain('localhost:80') diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 48062417e..6b9549c71 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -81,8 +81,8 @@ export interface InitDevframeOptions { * Authentication for the RPC endpoint. A handler mounted inside an app * server is reachable by anything that can open its socket, so it **gates * by default**: when unset (or `true`), devframe's interactive OTP handler - * is wired and its code/link banner prints once the public origin is known - * (derived from the first request, or `origin`). Pass a + * is wired and its code/link banner prints when an untrusted client asks + * for a code (the client's `requestAuthCode()`). Pass a * {@link DevframeAuthHandler} for a custom scheme, or `false` to opt out * for a single-user localhost setup that owns the trust boundary another * way. Ignored for the `ws.url` tier, since the server behind that URL owns auth. diff --git a/packages/devframe/src/client/rpc-auth-gate.test.ts b/packages/devframe/src/client/rpc-auth-gate.test.ts index 32a7993c7..f295fca55 100644 --- a/packages/devframe/src/client/rpc-auth-gate.test.ts +++ b/packages/devframe/src/client/rpc-auth-gate.test.ts @@ -26,6 +26,7 @@ vi.mock('./rpc-ws', () => ({ }), requestTrustWithToken: async () => true, requestTrustWithCode: async () => null, + requestAuthCode: async () => {}, call: fakeMode.call as DevframeRpcClientMode['call'], callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'], callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'], diff --git a/packages/devframe/src/client/rpc-live.ts b/packages/devframe/src/client/rpc-live.ts index 8f907e8ce..00c050568 100644 --- a/packages/devframe/src/client/rpc-live.ts +++ b/packages/devframe/src/client/rpc-live.ts @@ -235,6 +235,14 @@ export function createLiveRpcClientMode( return token } + async function requestAuthCode(options: { reissue?: boolean } = {}): Promise { + await serverRpc.$call('anonymous:devframe:auth:request-code', { + ua: navigator.userAgent, + origin: location.origin, + ...(options.reissue ? { reissue: true } : {}), + }) + } + async function requestTrust() { if (isTrusted) return true @@ -281,6 +289,7 @@ export function createLiveRpcClientMode( requestTrust, requestTrustWithToken, requestTrustWithCode, + requestAuthCode, ensureTrusted, call: (...args: any): any => { const method = String(args[0]) diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts index d3a616530..acae94e02 100644 --- a/packages/devframe/src/client/rpc-static.ts +++ b/packages/devframe/src/client/rpc-static.ts @@ -25,6 +25,8 @@ export async function createStaticRpcClientMode( requestTrustWithToken: async () => true, /** Static backends are always trusted, so there's nothing to exchange. */ requestTrustWithCode: async () => null, + /** No server terminal to print a code in. */ + requestAuthCode: async () => {}, ensureTrusted: async () => true, call: (...args: any): any => staticCaller.call( args[0] as string, diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 48df62dc4..c195a772d 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -190,6 +190,14 @@ export interface DevframeRpcClient { */ requestTrustWithCode: (code: string) => Promise + /** + * Ask the server to print its one-time code banner in the terminal, e.g. + * when a custom auth UI is shown. Pass `reissue: true` to rotate the code + * first (a "re-issue" button), guaranteeing a freshly-valid code; without + * it the server prints each code at most once. + */ + requestAuthCode: (options?: { reissue?: boolean }) => Promise + /** * Call a RPC function on the server */ @@ -276,6 +284,7 @@ export interface DevframeRpcClientMode { * token on success (for the caller to persist), or `null` on failure. */ requestTrustWithCode: (code: string) => Promise + requestAuthCode: DevframeRpcClient['requestAuthCode'] call: DevframeRpcClient['call'] callEvent: DevframeRpcClient['callEvent'] callOptional: DevframeRpcClient['callOptional'] @@ -476,6 +485,7 @@ export async function getDevframeRpcClient( catch {} return true }, + requestAuthCode: options => mode.requestAuthCode(options), call: gateOnBootstrapAuth(mode.call), callEvent: gateOnBootstrapAuth(mode.callEvent), callOptional: gateOnBootstrapAuth(mode.callOptional), @@ -534,6 +544,9 @@ export async function getDevframeRpcClient( return if (typeof globalThis.prompt !== 'function') return + // Make sure the terminal actually shows a code before asking for it; the + // server only prints its banner on request. + await rpc.requestAuthCode().catch(() => {}) 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') diff --git a/packages/devframe/src/node/auth/handler.ts b/packages/devframe/src/node/auth/handler.ts index 1cdd1e1f2..fbe965de7 100644 --- a/packages/devframe/src/node/auth/handler.ts +++ b/packages/devframe/src/node/auth/handler.ts @@ -16,8 +16,9 @@ import type { DevframeNodeRpcSession } from 'devframe/types' export interface DevframeAuthHandler { /** * `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (the - * handshake) and `devframe:auth:revoke` (self-revoke); register these on - * the RPC host (e.g. `rpcHost.register(fn)` for each). + * handshake), `anonymous:devframe:auth:request-code` (client-requested + * banner print), and `devframe:auth:revoke` (self-revoke); register these + * on the RPC host (e.g. `rpcHost.register(fn)` for each). */ rpcFunctions: RpcFunctionDefinitionAny[] /** @@ -35,9 +36,11 @@ export interface DevframeAuthHandler { */ onConnect: (connection: DevframeRpcConnection, session: DevframeNodeRpcSession) => void /** - * Print the current one-time code and its magic-link URL. Devframe stays - * headless, so call this yourself once the server is listening. Safe to - * call repeatedly; it only prints once per code. + * Print the current one-time code and its magic-link URL. An untrusted + * browser client triggers this itself over + * `anonymous:devframe:auth:request-code`; call it directly when the host + * wants the code on screen without waiting for a client. Safe to call + * repeatedly; it only prints once per code. */ printBanner: () => void /** diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index f0135c232..93ec39dc1 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -15,7 +15,7 @@ import { parseUA } from 'ua-parser-modern' * happens here, at the server ingress, keeping the persisted label format * identical. */ -function describeUA(userAgent: string): string { +export function describeUA(userAgent: string): string { const info = parseUA(userAgent) return [ info.browser.name, @@ -63,6 +63,18 @@ export function getTempAuthCode(): string { return ensureTempAuthCode() } +/** + * The current code plus its expiry timestamp, for display (e.g. the auth + * banner). An already-expired code is rotated first, so the returned code is + * always redeemable for its remaining lifetime. + */ +export function getTempAuthCodeInfo(): { code: string, expireAt: number } { + ensureTempAuthCode() + if (Date.now() > tempAuthCodeExpiresAt) + refreshTempAuthCode() + return { code: tempAuthCode!, expireAt: tempAuthCodeExpiresAt } +} + /** * Rotate the authentication code, resetting its expiry window and failed-attempt * counter. Call this when a new authentication flow begins (e.g. when an diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index ac0974616..dfae17608 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -494,9 +494,9 @@ function respondWith(event: H3Event, response: Response): ReadableStream | strin /** * The shared machinery behind `initDevframe` and `initHub`: one mount base, - * one h3 app, one lazily-derived public origin (and the auth banner that waits - * for it), one WebSocket binding, and the fetch / connect-middleware pair that - * serves them. Each factory supplies only what makes it itself (its context, + * one h3 app, one lazily-derived public origin (which backs the auth + * banner's magic link), one WebSocket binding, and the fetch / + * connect-middleware pair that serves them. Each factory supplies only what makes it itself (its context, * its routes, its diagnostics) through `init` / `mount`. * * Nothing here listens on a port unless a side-car was explicitly requested: @@ -551,8 +551,9 @@ export function createInstanceShell( const advertisedSsePath = options.absoluteWsPath ? sseRoutePath : sseRoute // The public origin is often unknowable at creation (the host app owns the - // listener), so derive it from the first request and let the auth banner - // wait for it, unless the caller pinned one (as a string or a getter). + // listener), so derive it from the first request, unless the caller pinned + // one. A client requests the auth banner only after fetching + // `__connection.json`, so the origin is known before the magic link is built. let derivedOrigin: string | undefined function explicitOrigin(): string | undefined { return typeof options.origin === 'function' ? options.origin() : options.origin @@ -561,13 +562,6 @@ export function createInstanceShell( return explicitOrigin() || derivedOrigin } let authHandler: DevframeAuthHandler | undefined - let bannerPrinted = false - function maybePrintBanner(): void { - if (bannerPrinted || !authHandler || !currentOrigin()) - return - bannerPrinted = true - authHandler.printBanner() - } let meta: ConnectionMeta | undefined let registration: DevframeInstanceRegistration | undefined @@ -612,7 +606,7 @@ export function createInstanceShell( * adopts only a loopback host or an exact `allowedOrigins` match, so a raw * inbound `Host`/URL authority never redirects the credential-bearing link. * First-valid-origin wins: an invalid candidate leaves `derivedOrigin` unset - * (printing/registering nothing) so a later valid one can still be adopted. + * (registering nothing) so a later valid one can still be adopted. */ function noteOrigin(candidate: string): void { if (derivedOrigin === undefined && !explicitOrigin()) { @@ -621,7 +615,6 @@ export function createInstanceShell( if (accepted !== undefined) derivedOrigin = accepted } - maybePrintBanner() maybeRegister() } @@ -835,9 +828,8 @@ export function createInstanceShell( await options.mount?.(ctx, meta, api) - // A pinned origin means the banner and registry record needn't wait for a - // first request. - maybePrintBanner() + // A pinned origin means the registry record needn't wait for a first + // request. maybeRegister() } diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index cddcf0332..fdff56e85 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -1,4 +1,5 @@ import type { DevframeHost, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { AuthBannerInfo } from '../interactive-auth' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -27,7 +28,7 @@ async function createTestContext(): Promise { /** Starts a fully-authenticated server with one trusted-only probe method. */ async function startAuthenticatedServer( - banners: { code: string, url: string }[] = [], + banners: AuthBannerInfo[] = [], preTrust = false, onTrusted?: (info: { authToken: string }) => void, ) { @@ -73,6 +74,7 @@ describe('recipes/interactive-auth', () => { expect(auth.rpcFunctions.map(fn => fn.name)).toEqual([ 'anonymous:devframe:auth', 'anonymous:devframe:auth:exchange', + 'anonymous:devframe:auth:request-code', 'devframe:auth:revoke', ]) expect(typeof auth.authorize).toBe('function') @@ -93,14 +95,45 @@ describe('recipes/interactive-auth', () => { expect(auth.authorize('some-plugin:do-something', session)).toBe(true) }) - it('printBanner() only prints once per code', async () => { + it('printBanner() only prints once per code, with the code expiry', async () => { const context = await createTestContext() - const seen: { code: string, url: string }[] = [] + const seen: AuthBannerInfo[] = [] const auth = createInteractiveAuth(context, { banner: info => seen.push(info) }) auth.printBanner() auth.printBanner() expect(seen).toHaveLength(1) + expect(seen[0]!.code).toBe(getTempAuthCode()) + expect(seen[0]!.expireAt).toBeGreaterThan(Date.now()) + expect(seen[0]!.requester).toBeUndefined() + }) + + it('request-code RPC prints the banner with the requester, dedupes per code, and reissue rotates it', async () => { + const banners: AuthBannerInfo[] = [] + const { server, host, port } = await startAuthenticatedServer(banners) + + try { + const client = connectClient(host, port) + const requester = { ua: 'test', origin: 'http://localhost:5173' } + + // First hit of the auth view prints the current code once. + await client.$call('anonymous:devframe:auth:request-code', requester) + await client.$call('anonymous:devframe:auth:request-code', requester) + expect(banners).toHaveLength(1) + expect(banners[0]!.code).toBe(getTempAuthCode()) + expect(banners[0]!.expireAt).toBeGreaterThan(Date.now()) + expect(banners[0]!.requester?.origin).toBe('http://localhost:5173') + + // The manual "re-issue" button rotates the code and always prints. + await client.$call('anonymous:devframe:auth:request-code', { ...requester, reissue: true }) + expect(banners).toHaveLength(2) + expect(banners[1]!.code).not.toBe(banners[0]!.code) + expect(banners[1]!.code).toBe(getTempAuthCode()) + client.$close() + } + finally { + await server.close() + } }) it('round-trips: untrusted connect -> exchange -> trusted -> reconnect with the returned bearer, no new code', async () => { @@ -137,13 +170,11 @@ describe('recipes/interactive-auth', () => { } }) - it('onTrusted() fires once a code exchange succeeds, after the rotated code is printed', async () => { - const banners: { code: string, url: string }[] = [] - const trusted: { authToken: string, bannerCountAtCall: number, lastBannerCode?: string }[] = [] + it('onTrusted() fires once a code exchange succeeds; an exchange never prints a banner', async () => { + const banners: AuthBannerInfo[] = [] + const trusted: { authToken: string }[] = [] const { server, host, port } = await startAuthenticatedServer(banners, false, info => trusted.push({ authToken: info.authToken, - bannerCountAtCall: banners.length, - lastBannerCode: banners.at(-1)?.code, })) try { @@ -155,8 +186,8 @@ describe('recipes/interactive-auth', () => { const { authToken } = await client.$call('anonymous:devframe:auth:exchange', { code, ua: 'test', origin: 'http://localhost' }) expect(trusted.map(info => info.authToken)).toEqual([authToken]) - expect(trusted[0]!.bannerCountAtCall).toBe(banners.length) - expect(trusted[0]!.lastBannerCode).toBe(getTempAuthCode()) + // Printing is on-demand only (view mount / re-issue / printBanner()). + expect(banners).toHaveLength(0) client.$close() } finally { diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index b3ab31dfa..194309792 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -4,7 +4,7 @@ import type { DevframeAuthHandler } from '../node/auth' import { colors } from 'devframe/utils/colors' import { s } from 'devframe/utils/simple-schema' import { DEVFRAME_AUTH_TOKEN_QUERY_PARAM, isAnonymousRpcMethod } from '../constants' -import { buildOtpAuthUrl, exchangeTempAuthCode, getTempAuthCode, verifyAuthToken } from '../node/auth/state' +import { buildOtpAuthUrl, describeUA, exchangeTempAuthCode, getTempAuthCodeInfo, refreshTempAuthCode, verifyAuthToken } from '../node/auth/state' import { getInternalContext } from '../node/hub-internals/context' import { defineRpcFunction } from '../rpc/define' @@ -17,20 +17,18 @@ export interface CreateInteractiveAuthOptions { */ clientAuthTokens?: string[] /** - * Print the current code + magic-link URL. Devframe stays headless, so - * there is no default banner printed automatically; call - * `auth.printBanner()` yourself once the server is listening. Defaults to - * {@link createAuthBanner}'s output; pass its result here directly to - * rebrand the box (title / colors), or your own function to replace the - * format outright. + * Print the current code + magic-link URL. Runs when an untrusted browser + * client asks for a code (`anonymous:devframe:auth:request-code`, sent by + * the client's `requestAuthCode()`) or when the host calls + * `auth.printBanner()` itself. Defaults to {@link createAuthBanner}'s + * output; pass its result here directly to rebrand the box (title / + * colors), or your own function to replace the format outright. */ banner?: AuthBannerFunction /** * Called once a code exchange succeeds, so a host rendering its own - * banner can retract it. Fires after the rotated code is printed, so - * such a host drops that follow-up too and calls `auth.printBanner()` - * when it next wants a code on screen. Connect-time trust from a static - * or remote-dock token doesn't call this. + * banner can retract it. Connect-time trust from a static or + * remote-dock token doesn't call this. */ onTrusted?: (info: { session: DevframeNodeRpcSession, authToken: string }) => void /** @@ -40,8 +38,26 @@ export interface CreateInteractiveAuthOptions { serverUrl?: () => string } +/** The browser client whose `anonymous:devframe:auth:request-code` call triggered a banner print. */ +export interface AuthBannerRequester { + /** Short display label parsed from the client's user agent (e.g. `Chrome 120 | macOS 14 desktop`). */ + ua: string + /** The requesting page's `location.origin`. */ + origin: string +} + +/** What `options.banner` receives on each print. */ +export interface AuthBannerInfo { + code: string + url: string + /** Epoch-ms timestamp the code stops being redeemable at. */ + expireAt: number + /** Present when a browser client requested the print; absent for a host's own `printBanner()` call. */ + requester?: AuthBannerRequester +} + /** Signature of `options.banner`: render the current auth code + magic-link URL. */ -export type AuthBannerFunction = (info: { code: string, url: string }) => void +export type AuthBannerFunction = (info: AuthBannerInfo) => void /** Palette for {@link createAuthBanner}'s box - one color per part, so a host can rebrand a subset. */ export interface CreateAuthBannerColorsOptions { @@ -70,9 +86,9 @@ export function createAuthBanner(options: CreateAuthBannerOptions = {}): AuthBan const title = options.title ?? 'Devframe' const palette: CreateAuthBannerColorsOptions = { border: colors.dim, - title: colors.bold, + title: x => colors.gray(colors.bold(x)), label: colors.dim, - code: colors.bold, + code: f => colors.green(colors.bold(f)), url: colors.cyan, ...options.colors, } @@ -81,14 +97,19 @@ export function createAuthBanner(options: CreateAuthBannerOptions = {}): AuthBan const rows: [label: string, value: string, color: ColorFn][] = [ ['auth code', info.code, palette.code], ['or open', info.url, palette.url], + ['expires at', new Date(info.expireAt).toLocaleTimeString(), palette.label], ] + if (info.requester) { + rows.push(['ua', info.requester.ua, palette.label]) + rows.push(['origin', info.requester.origin, palette.label]) + } const labelWidth = Math.max(...rows.map(([label]) => label.length)) const contentWidth = Math.max(...rows.map(([, value]) => labelWidth + 2 + value.length)) - const titleBarLength = title.length + 3 + const titleBarLength = title.length + 2 const lineWidth = Math.max(contentWidth, titleBarLength - 2) const top = [ - palette.border(`╭─`), + palette.border(`╭`), palette.title(title), palette.border(`${'─'.repeat(Math.max(lineWidth + 2 - titleBarLength, 0))}╮`), ].join(' ') @@ -144,13 +165,13 @@ export function createInteractiveAuth( const banner = options.banner ?? createAuthBanner() let bannerPrintedForCode: string | undefined - function printBanner(): void { - const code = getTempAuthCode() + function printBanner(info?: { requester?: AuthBannerRequester }): void { + const { code, expireAt } = getTempAuthCodeInfo() if (code === bannerPrintedForCode) return bannerPrintedForCode = code const url = buildOtpAuthUrl(resolveServerUrl(), code) - banner({ code, url }) + banner({ code, url, expireAt, ...(info?.requester ? { requester: info.requester } : {}) }) } const anonymousAuth = defineRpcFunction({ @@ -193,15 +214,32 @@ export function createInteractiveAuth( if (!session) return { authToken: null } const authToken = exchangeTempAuthCode(params.code, session, params, storage) - // The code was just consumed (success or a rotating failure); the - // next `printBanner()` call shows whatever code is current now. - printBanner() if (authToken) options.onTrusted?.({ session, authToken }) return { authToken } }, }) + const anonymousAuthRequestCode = defineRpcFunction({ + name: 'anonymous:devframe:auth:request-code', + type: 'action', + jsonSerializable: true, + args: [s.object({ + ua: s.string(), + origin: s.string(), + reissue: s.optional(s.boolean()), + })], + returns: s.void(), + handler(params) { + // `reissue` rotates the code first (the manual "re-issue" button), so + // the print always shows a freshly-valid code; a plain request prints + // the current code at most once (per-code dedupe in `printBanner`). + if (params.reissue) + refreshTempAuthCode() + printBanner({ requester: { ua: describeUA(params.ua), origin: params.origin } }) + }, + }) + const revoke = defineRpcFunction({ name: 'devframe:auth:revoke', type: 'action', @@ -262,7 +300,7 @@ export function createInteractiveAuth( } return { - rpcFunctions: [anonymousAuth, anonymousAuthExchange, revoke], + rpcFunctions: [anonymousAuth, anonymousAuthExchange, anonymousAuthRequestCode, revoke], authorize, onConnect: onConnect as DevframeAuthHandler['onConnect'], printBanner, diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 9feab019f..b851fd105 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -75,6 +75,19 @@ export interface DevframeRpcServerFunctions { * @internal */ 'anonymous:devframe:auth:exchange': (params: { code: string, ua: string, origin: string }) => Promise<{ authToken: string | null }> + /** + * Ask the server to print its auth banner (code + magic link) for this + * client, e.g. when an auth UI first shows; `reissue: true` rotates the code + * first (the manual "re-issue" action). Registered by + * `recipes/interactive-auth`; a repeat request for an already-printed code + * is a no-op. + * + * Named with the `anonymous:` prefix (see `isAnonymousRpcMethod`) so it is + * reachable before the connection is trusted. + * + * @internal + */ + 'anonymous:devframe:auth:request-code': (params: { ua: string, origin: string, reissue?: boolean }) => Promise /** * Self-revoke: the caller asks the server to revoke its own bearer token * (if any) and drop to untrusted. Requires an already-trusted caller, so diff --git a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue index 88d87225e..f1934655b 100644 --- a/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue +++ b/packages/hub-ui/src/client/components/views-builtin/ViewBuiltinClientAuthNotice.vue @@ -1,7 +1,7 @@