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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion docs/content/1.guide/11.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
```

Expand Down
8 changes: 4 additions & 4 deletions docs/content/1.guide/14.security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/content/2.adapters/1.initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/content/8.references/10.interactive-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,18 @@ 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. |

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

Expand All @@ -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`.
1 change: 1 addition & 0 deletions docs/content/8.references/4.node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
5 changes: 4 additions & 1 deletion examples/custom-hub-next/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions examples/custom-hub-vite/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
87 changes: 54 additions & 33 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Parameters<typeof initDevframe>[1]>,
run: (devtools: ReturnType<typeof initDevframe>, spy: ReturnType<typeof vi.spyOn>) => Promise<void>,
run: (
devtools: ReturnType<typeof initDevframe>,
spy: ReturnType<typeof vi.spyOn>,
requestBanner: () => Promise<void>,
) => Promise<void>,
): Promise<void> {
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<typeof connectWsClient> | undefined
try {
await devtools.ready
await run(devtools, spy)
client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`)
const requestBanner = async (): Promise<void> => {
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()
}
Expand All @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/client/rpc-auth-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading
Loading