Skip to content

Commit e27579b

Browse files
antfubotantfu
andauthored
feat(auth): print the OTP banner on client demand, with expiry and requester info (#368)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent b00dd6f commit e27579b

32 files changed

Lines changed: 373 additions & 289 deletions

File tree

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# pnpm patch files must stay LF: a CRLF checkout (Windows autocrlf) breaks
2+
# pnpm's patch parser with ERR_PNPM_INVALID_PATCH.
3+
*.patch text eol=lf

docs/content/1.guide/11.client.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ if (!trusted) {
8787

8888
### Authenticating with a one-time code
8989

90-
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:
90+
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:
9191

9292
```ts
93+
await rpc.requestAuthCode()
94+
// … the developer reads the code from the terminal …
9395
const ok = await rpc.requestTrustWithCode('047204')
9496
```
9597

docs/content/1.guide/14.security.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ An RPC handler runs with the full privileges of its Node process (filesystem, ch
1919
2020
## The pre-trust gate
2121

22-
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.
22+
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.
2323

2424
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.
2525

2626
## Authentication flow
2727

2828
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.
29-
2. The dev server shows a 6-digit code in the terminal (`auth.printBanner()` once listening).
29+
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.
3030
3. The developer enters it; the browser calls `requestTrustWithCode(code)`.
3131
4. The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it.
3232
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 `
5151

5252
### Auth methods
5353

54-
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).
54+
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).
5555

5656
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).
5757

58-
RPC client methods (`devframe/client`): `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
58+
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).
5959

6060
### Magic-link authentication
6161

docs/content/2.adapters/1.initiate.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so
129129

130130
## Auth
131131

132-
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.
132+
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.
133133

134134
## Relation to the other adapters
135135

docs/content/8.references/10.interactive-auth.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,18 @@ As `auth` it wires `rpcFunctions`, `authorize`, and `onConnect`; see [Security](
3030
| Option | Default | Purpose |
3131
|--------|---------|---------|
3232
| `clientAuthTokens` | `undefined` | Pre-shared bearer tokens, always trusted. |
33-
| `banner` | a small boxed console message | Called with `{ code, url }`; prints via `printBanner()`. |
33+
| `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()`. |
3434
| `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. |
3535
| `serverUrl` | `context.host.resolveOrigin()` | Magic-link base URL. |
3636

3737
Returns a `DevframeAuthHandler`:
3838

3939
| Field | Purpose |
4040
|-------|---------|
41-
| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (handshake), `devframe:auth:revoke` (self-revoke). |
41+
| `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). |
4242
| `authorize(methodName, session)` | Resolver gate: allows `anonymous:` methods, else requires `session.meta.isTrusted`. |
4343
| `onConnect(peer, session)` | Connect-time trust from a bearer on the WS upgrade URL (`?devframe_auth_token=`). |
44-
| `printBanner()` | Prints the code + magic-link URL. |
44+
| `printBanner()` | Prints the code + magic-link URL, at most once per code. |
4545

4646
## Using the pieces directly
4747

@@ -60,6 +60,6 @@ if (!auth.authorize(methodName, session))
6060
auth.onConnect(peer, session)
6161
```
6262

63-
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.
63+
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.
6464

6565
Auth storage is internal, not `devframe/node/hub-internals`.

docs/content/8.references/4.node-api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ The wire-level RPC methods of the trust handshake: [Security](/guide/security#au
180180
|------------|-----------|-------|
181181
| `anonymous:devframe:auth` | client → server | `{ authToken, ua, origin }``{ isTrusted }`: re-authenticate a stored token |
182182
| `anonymous:devframe:auth:exchange` | client → server | `{ code, ua, origin }``{ authToken \| null }`: exchange a code for a token |
183+
| `anonymous:devframe:auth:request-code` | client → server | `{ ua, origin, reissue? }` → print the code banner in the server terminal (`reissue: true` rotates the code first) |
183184
| `devframe:auth:revoke` | client → server | self-revoke the caller's own token |
184185
| `devframe:auth:revoked` | server → client | event: token revoked |
185186

examples/custom-hub-next/src/client/app/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,10 @@ function AuthOverlay({ rpc }: { rpc: DevframeRpcClient }) {
264264

265265
useEffect(() => {
266266
inputRef.current?.focus()
267-
}, [])
267+
// The server prints its code banner on request; ask once when this
268+
// overlay first shows (an already-authorized page never mounts it).
269+
void rpc.requestAuthCode().catch(() => {})
270+
}, [rpc])
268271

269272
async function submit(event: FormEvent) {
270273
event.preventDefault()

examples/custom-hub-vite/src/client/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ function createAuthOverlay(
309309
return
310310
overlay.hidden = false
311311
setStatus('Waiting for authorization…')
312+
// The server prints its code banner on request; ask once when this
313+
// overlay first shows (an already-authorized page never reveals it).
314+
void rpc.requestAuthCode().catch(() => {})
312315
input.focus()
313316
},
314317
remove: () => overlay.remove(),

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

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -103,25 +103,29 @@ describe('adapters/handler', () => {
103103

104104
try {
105105
await devtools.ready
106-
// The banner waits for the public origin: unknown until a request
107-
// arrives, then printed exactly once (the magic link points at the
108-
// origin the handler is actually mounted on).
109-
expect(spy).not.toHaveBeenCalled()
110-
await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json'))
111-
expect(spy).toHaveBeenCalledTimes(1)
112-
expect(String(spy.mock.calls[0])).toContain('http://localhost:4321')
106+
// The banner is on demand: a plain request derives the public origin
107+
// but prints nothing, even for an already-authorized page.
113108
await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json'))
114-
expect(spy).toHaveBeenCalledTimes(1)
109+
expect(spy).not.toHaveBeenCalled()
115110

116111
const client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`)
117112
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
118113
expect(handshake).toEqual({ isTrusted: false })
119114
await expect(client.$call('test:probe' as any)).rejects.toThrow()
120115

116+
// An untrusted client requests the code (the auth view's mount call):
117+
// printed once per code, with the magic link on the derived origin.
118+
await client.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost' })
119+
await client.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost' })
120+
expect(spy).toHaveBeenCalledTimes(1)
121+
expect(String(spy.mock.calls[0])).toContain('http://localhost:4321')
122+
121123
const code = getTempAuthCode()
122124
const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null }
123125
expect(exchange.authToken).toBeTruthy()
124126
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
127+
// The exchange rotates the code without printing the new one.
128+
expect(spy).toHaveBeenCalledTimes(1)
125129
client.$close()
126130
}
127131
finally {
@@ -426,22 +430,32 @@ describe('adapters/handler', () => {
426430
})
427431

428432
// The auth-link origin is derived from the served request's URL (the fetch
429-
// handler ignores the `Host` header; that path is `nodeMiddleware`'s), so
430-
// each case just points a request at the origin under test and inspects the
431-
// one-time banner (`console.log`).
433+
// handler ignores the `Host` header), so each case points a request at the
434+
// origin under test, then requests a banner over RPC (`reissue` rotates the
435+
// code past the per-code dedupe) and inspects the printed link.
432436
async function withBannerSpy(
433437
id: string,
434438
extra: Partial<Parameters<typeof initDevframe>[1]>,
435-
run: (devtools: ReturnType<typeof initDevframe>, spy: ReturnType<typeof vi.spyOn>) => Promise<void>,
439+
run: (
440+
devtools: ReturnType<typeof initDevframe>,
441+
spy: ReturnType<typeof vi.spyOn>,
442+
requestBanner: () => Promise<void>,
443+
) => Promise<void>,
436444
): Promise<void> {
437445
const wsPort = await getPort({ host: '127.0.0.1' })
438446
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
439447
const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra })
448+
let client: ReturnType<typeof connectWsClient> | undefined
440449
try {
441450
await devtools.ready
442-
await run(devtools, spy)
451+
client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`)
452+
const requestBanner = async (): Promise<void> => {
453+
await client!.$call('anonymous:devframe:auth:request-code' as any, { ua: 'test', origin: 'http://localhost', reissue: true })
454+
}
455+
await run(devtools, spy, requestBanner)
443456
}
444457
finally {
458+
client?.$close()
445459
spy.mockRestore()
446460
await devtools.close()
447461
}
@@ -450,47 +464,54 @@ describe('adapters/handler', () => {
450464
devtools.handler(new Request(`${origin}/__connection.json`))
451465

452466
it('a hostile first request never becomes the OTP-link origin; a later loopback one does', () =>
453-
withBannerSpy('h-poison', {}, async (devtools, spy) => {
454-
// A forged non-loopback origin is not adopted and prints nothing.
467+
withBannerSpy('h-poison', {}, async (devtools, spy, requestBanner) => {
468+
// A forged non-loopback origin is not adopted: a banner requested now
469+
// falls back to the loopback default, never the forged authority.
455470
await hit(devtools, 'http://evil.example.com/__h-poison')
456-
expect(spy).not.toHaveBeenCalled()
457-
// A later loopback origin is adopted and prints exactly one OTP link
458-
// (the credential rides the fragment); the reject never locked it out.
459-
await hit(devtools, 'http://localhost:4321/__h-poison')
471+
await requestBanner()
460472
expect(spy).toHaveBeenCalledTimes(1)
461-
expect(String(spy.mock.calls[0])).toContain('http://localhost:4321/#devframe_otp=')
462473
expect(String(spy.mock.calls[0])).not.toContain('evil.example.com')
474+
// A later loopback origin is adopted and the OTP link points at it
475+
// (the credential rides the fragment); the reject never locked it out.
476+
await hit(devtools, 'http://localhost:4321/__h-poison')
477+
await requestBanner()
478+
expect(String(spy.mock.calls[1])).toContain('http://localhost:4321/#devframe_otp=')
463479
// First-valid origin is pinned: a second loopback request doesn't move it.
464480
await hit(devtools, 'http://127.0.0.1:9999/__h-poison')
465-
expect(spy).toHaveBeenCalledTimes(1)
481+
await requestBanner()
482+
expect(String(spy.mock.calls[2])).toContain('http://localhost:4321/#')
466483
}))
467484

468485
it('adopts an exactly allow-listed non-loopback origin, but rejects a near-match', () =>
469-
withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy) => {
470-
// Prefix/suffix near-matches of the allow-list entry are never adopted.
486+
withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy, requestBanner) => {
487+
// Prefix/suffix near-matches of the allow-list entry are never adopted;
488+
// the link stays on the loopback fallback.
471489
await hit(devtools, 'https://tools.example.com.evil.com/__h-allow')
472490
await hit(devtools, 'https://evil.tools.example.com/__h-allow')
473-
expect(spy).not.toHaveBeenCalled()
491+
await requestBanner()
492+
expect(String(spy.mock.calls[0])).toContain('http://localhost/#')
493+
expect(String(spy.mock.calls[0])).not.toContain('evil')
474494
// The exact allow-listed origin is.
475495
await hit(devtools, 'https://tools.example.com/__h-allow')
476-
expect(spy).toHaveBeenCalledTimes(1)
477-
expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#')
496+
await requestBanner()
497+
expect(String(spy.mock.calls[1])).toContain('https://tools.example.com/#')
478498
}))
479499

480500
it('an explicit origin wins over any request', () =>
481-
withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy) => {
482-
// Pinned: the banner points at it before any request, and a forged
483-
// request can't move it.
484-
expect(spy).toHaveBeenCalledTimes(1)
501+
withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy, requestBanner) => {
502+
// Pinned: the banner points at it, and a forged request can't move it.
503+
await requestBanner()
485504
expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#')
486505
await hit(devtools, 'http://evil.example.com/__h-pinned')
487-
expect(spy).toHaveBeenCalledTimes(1)
488-
expect(String(spy.mock.calls[0])).not.toContain('evil.example.com')
506+
await requestBanner()
507+
expect(String(spy.mock.calls[1])).toContain('https://pinned.example.com/#')
508+
expect(String(spy.mock.calls[1])).not.toContain('evil.example.com')
489509
}))
490510

491511
it('canonicalizes an adopted origin, dropping the default port', () =>
492-
withBannerSpy('h-canon', {}, async (devtools, spy) => {
512+
withBannerSpy('h-canon', {}, async (devtools, spy, requestBanner) => {
493513
await hit(devtools, 'http://localhost:80/__h-canon')
514+
await requestBanner()
494515
expect(spy).toHaveBeenCalledTimes(1)
495516
expect(String(spy.mock.calls[0])).toContain('http://localhost/#')
496517
expect(String(spy.mock.calls[0])).not.toContain('localhost:80')

packages/devframe/src/adapters/initiate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ export interface InitDevframeOptions {
8181
* Authentication for the RPC endpoint. A handler mounted inside an app
8282
* server is reachable by anything that can open its socket, so it **gates
8383
* by default**: when unset (or `true`), devframe's interactive OTP handler
84-
* is wired and its code/link banner prints once the public origin is known
85-
* (derived from the first request, or `origin`). Pass a
84+
* is wired and its code/link banner prints when an untrusted client asks
85+
* for a code (the client's `requestAuthCode()`). Pass a
8686
* {@link DevframeAuthHandler} for a custom scheme, or `false` to opt out
8787
* for a single-user localhost setup that owns the trust boundary another
8888
* way. Ignored for the `ws.url` tier, since the server behind that URL owns auth.

0 commit comments

Comments
 (0)