Skip to content

Commit d413ef4

Browse files
committed
feat(auth)!: ready-made interactive auth handler with real enforcement
Implements the auth-side upstream proposals surfaced by the Vite DevTools 0.6 migration (devframes/devframe issues): a single pre-trust rule, a packaged OTP auth handler, and an authenticated `startHttpAndWs`. BREAKING CHANGE (wire-level, `@internal` methods): the two pre-auth handshake methods are renamed to carry the `anonymous:` prefix so the pre-trust gate has one rule to apply, not a hand-maintained allowlist: - `devframe:anonymous:auth` -> `anonymous:devframe:auth` - `devframe:auth:exchange` -> `anonymous:devframe:auth:exchange` - `devframe/constants` exports `ANONYMOUS_RPC_PREFIX` / `isAnonymousRpcMethod`, the single source of truth for what's callable before trust. - `devframe/recipes/interactive-auth`'s `createInteractiveAuth(ctx, options)` packages the OTP protocol devframe's primitives already implement (`exchangeTempAuthCode`, `verifyAuthToken`, `revokeAuthToken`, `getTempAuthCode`, `buildOtpAuthUrl`) into a `DevframeAuthHandler` — handshake RPC functions, the resolver gate, a connect-time trust hook that reads a bearer off the WS upgrade URL, and the code/link banner. The auth storage stays internal to the handler. - `startHttpAndWs` accepts that handler (or a lower-level `authorize` / `onPeerConnect` pair) directly via `auth`, and now actually enforces it: an untrusted call to a non-`anonymous:` method throws `DF0036`. `StartedServer.connectionMeta()` returns the `__connection.json` shape a host would otherwise hand-roll. - Client: adds a standalone `forgetAuthToken()` export (clear a persisted token) to pair with the new trusted-only `devframe:auth:revoke` server method for giving up the current session's token. - Docs: rewrite the Security guide's auth-methods table for the new names and the ready-made handler, add the `DF0036` error page, and add an Interactive Auth helper page. Also clarifies the terminals hub-aggregation model (`ctx.terminals` as the single source of truth for sessions, the plugin as the PTY-capable renderer that mirrors into it) in the terminals plugin docs, closing out the last of the five proposals. Generated with the help of an agent.
1 parent 455c801 commit d413ef4

29 files changed

Lines changed: 721 additions & 55 deletions

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function helpersItems(prefix: string): DefaultTheme.NavItemWithLink[] {
5555
{ text: 'Vite Bridge', link: `${prefix}/helpers/vite-bridge` },
5656
{ text: 'Nuxt Module', link: `${prefix}/helpers/nuxt` },
5757
{ text: 'Open Helpers', link: `${prefix}/helpers/open-helpers` },
58+
{ text: 'Interactive Auth', link: `${prefix}/helpers/interactive-auth` },
5859
]
5960
}
6061

docs/errors/DF0036.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0036: RPC Call Rejected — Not Authorized
6+
7+
## Message
8+
9+
> RPC call to "`{name}`" was rejected: the caller is not authorized.
10+
11+
## Cause
12+
13+
`startHttpAndWs` was configured with an `authorize` gate (either directly, or via a [`DevframeAuthHandler`](../guide/security) passed as `auth`) and the calling session hasn't satisfied it — the call is neither to an `anonymous:`-prefixed method (see `isAnonymousRpcMethod`) nor made by a trusted session.
14+
15+
## Example
16+
17+
```ts
18+
import { startHttpAndWs } from 'devframe/node'
19+
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
20+
21+
const auth = createInteractiveAuth(ctx)
22+
23+
await startHttpAndWs({ context: ctx, port: 9999, auth })
24+
25+
// A browser that hasn't completed the handshake yet can still reach the
26+
// handshake methods themselves…
27+
await client.call('anonymous:devframe:auth', { authToken: '', ua, origin })
28+
29+
// …but any other method throws DF0036 until the handshake succeeds.
30+
await client.call('some-plugin:do-something') // ✗ throws DF0036
31+
```
32+
33+
## Fix
34+
35+
- Complete the auth handshake — call `anonymous:devframe:auth` with a previously-issued token, or `anonymous:devframe:auth:exchange` with a one-time code — before calling a trusted method.
36+
- Connect with a static/pre-shared token (`createInteractiveAuth`'s `clientAuthTokens` option) for CI or shared-machine setups that should skip the interactive prompt.
37+
- If you supplied a custom `authorize` function, verify it allows the method you expect — it receives the raw method name and the session's `meta` (`isTrusted`, `clientAuthToken`, …).
38+
39+
## Source
40+
41+
- [`packages/devframe/src/node/server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/server.ts)`startHttpAndWs`'s resolver throws this when `authorize`/`auth.authorize` rejects a call.

docs/guide/hub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi
1616
| Subsystem | Surface | Purpose |
1717
|---|---|---|
1818
| `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. |
19-
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. |
19+
| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. |
2020
| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). |
2121
| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |
2222

docs/guide/security.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,57 @@ An RPC handler runs with the full privileges of the process hosting it — files
1212

1313
Two postures cover that boundary:
1414

15-
- **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 adaptere.g. Vite DevTools — provides the interactive handler and authentication UI.
15+
- **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/recipes/interactive-auth`'s `createInteractiveAuth` packages the whole protocol — handshake handlers, the resolver gate, connect-time trust, and the code/link bannerinto a single `DevframeAuthHandler` you pass straight to `startHttpAndWs({ auth })`.
1616
- **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.
1717

1818
> [!WARNING]
1919
> `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.
2020
21+
## The pre-trust gate
22+
23+
Exactly 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`). There is no separate allowlist to keep in sync — the two handshake methods below carry the prefix precisely because they're the only ones an unauthenticated caller needs.
24+
25+
`startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036).
26+
2127
## Authentication flow
2228

2329
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.
2430

25-
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.
26-
2. The dev server shows a 6-digit one-time code in the developer's terminal.
27-
3. The developer enters it; the browser calls `requestTrustWithCode(code)``devframe:auth:exchange`.
31+
1. A fresh client connects unauthenticated and calls `anonymous:devframe: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.
32+
2. The dev server shows a 6-digit one-time code in the developer's terminal — call `auth.printBanner()` once the server is listening; devframe stays headless otherwise.
33+
3. The developer enters it; the browser calls `requestTrustWithCode(code)``anonymous:devframe:auth:exchange`.
2834
4. The server verifies the code, mints a high-entropy bearer token, records it as trusted, marks the session trusted, and returns the token.
29-
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.
35+
5. The browser persists the token and presents it on reconnect (`anonymous:devframe:auth``verifyAuthToken`, or as a `?devframe_auth_token=` query param the connect-time hook checks before the handshake even runs); sibling tabs receive it over the `devframe-auth` channel and become trusted too.
3036

3137
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.
3238

33-
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.
39+
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. A client can give up its own token by calling `devframe:auth:revoke` and then `forgetAuthToken()` locally; a host can revoke on a client's behalf with `revokeAuthToken(context, storage, token)`. Either way, affected clients drop to untrusted via the `devframe:auth:revoked` event.
40+
41+
### The ready-made layer
42+
43+
```ts
44+
import { startHttpAndWs } from 'devframe/node'
45+
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
46+
47+
const auth = createInteractiveAuth(ctx, {
48+
clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined,
49+
})
50+
51+
const server = await startHttpAndWs({ context: ctx, port: 9999, auth })
52+
auth.printBanner()
53+
```
54+
55+
`createInteractiveAuth` closes over the auth storage internally — nothing here reaches into `devframe/node/hub-internals`. Pass `clientAuthTokens` for CI/shared machines that should skip the interactive prompt entirely, or a custom `banner`/`serverUrl` to change how the code is presented.
3456

3557
### Auth methods
3658

37-
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`).
59+
Devframe owns the wire contract; `createInteractiveAuth` registers the handlers on top of the `devframe/node/auth` primitives (the standalone server registers a noop auto-trust handler when `auth: false`).
3860

3961
| RPC method | Direction | Shape |
4062
|------------|-----------|-------|
41-
| `devframe:anonymous:auth` | client → server | `{ authToken, ua, origin }``{ isTrusted }` — re-authenticate a stored token |
42-
| `devframe:auth:exchange` | client → server | `{ code, ua, origin }``{ authToken \| null }` — exchange a one-time code for a token |
63+
| `anonymous:devframe:auth` | client → server | `{ authToken, ua, origin }``{ isTrusted }` — re-authenticate a stored token |
64+
| `anonymous:devframe:auth:exchange` | client → server | `{ code, ua, origin }``{ authToken \| null }` — exchange a one-time code for a token |
65+
| `devframe:auth:revoke` | client → server | — self-revoke: drop the caller's own token |
4366
| `devframe:auth:revoked` | server → client | event — the connection's token was revoked |
4467

4568
Node primitives (`devframe/node/auth`):
@@ -52,7 +75,7 @@ Node primitives (`devframe/node/auth`):
5275
| `buildOtpAuthUrl(origin, code?)` | build a magic-link URL embedding the code |
5376
| `revokeAuthToken(context, storage, token)` | delete a token and disconnect any sessions using it |
5477

55-
Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
78+
Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `forgetAuthToken()` (clear a persisted token — pair with a `devframe:auth:revoke` call to give up this browser's own token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).
5679

5780
### Magic-link authentication
5881

docs/helpers/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ Helpers are the optional, opt-in surface around the core `defineDevframe` API: s
1212
| [Vite Bridge](./vite-bridge) | `devframe/helpers/vite` | Vite plugin for mounting a devframe inside any Vite-based host (Astro, SolidStart, plain Vite). |
1313
| [Nuxt Module](./nuxt) | `@devframes/nuxt` | Nuxt module that wires a Nuxt SPA as a devframe client and serves the dev-time RPC bridge. |
1414
| [Open Helpers](./open-helpers) | `devframe/recipes/open-helpers` | Prebuilt RPC actions for "open in editor" and "reveal in Finder". |
15+
| [Interactive Auth](./interactive-auth) | `devframe/recipes/interactive-auth` | Ready-made OTP auth layer — handshake, resolver gate, connect-time trust, and the code/link banner. |
1516

1617
Helpers vs. [adapters](/adapters/): an adapter takes a `DevframeDefinition` and deploys it as a runnable surface (CLI, dev server, static build, MCP server). A helper is a smaller piece — a Vite plugin, a Nuxt module, a recipe, a utility function — that you compose alongside an adapter.

docs/helpers/interactive-auth.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Interactive Auth
6+
7+
A ready-made OTP auth layer over devframe's node-side primitives — the handshake RPC functions, the resolver gate, the connect-time trust hook, and the code/link banner — so a host doesn't re-implement the protocol on top of `exchangeTempAuthCode` / `verifyAuthToken` / `revokeAuthToken` itself.
8+
9+
```ts
10+
import { startHttpAndWs } from 'devframe/node'
11+
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
12+
13+
const auth = createInteractiveAuth(ctx, {
14+
clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined,
15+
})
16+
17+
const server = await startHttpAndWs({ context: ctx, port: 9999, auth })
18+
auth.printBanner()
19+
```
20+
21+
Passing the layer as `auth` registers its `rpcFunctions`, wires its `authorize` as the resolver gate, and wires its `onConnect` on every new peer — see [Security](../guide/security) for the full authentication flow this implements.
22+
23+
## `createInteractiveAuth(context, options?)`
24+
25+
| Option | Default | Purpose |
26+
|--------|---------|---------|
27+
| `clientAuthTokens` | `undefined` | Static, pre-shared bearer tokens that are always trusted — for CI runs or shared machines that should skip the interactive prompt. |
28+
| `banner` | a small boxed console message | Called with `{ code, url }` to present the current code. Devframe stays headless — nothing prints until you call `printBanner()`. |
29+
| `serverUrl` | `context.host.resolveOrigin()` | Base URL the magic link should point at. |
30+
31+
Returns a `DevframeAuthHandler`:
32+
33+
| Field | Purpose |
34+
|-------|---------|
35+
| `rpcFunctions` | `anonymous:devframe:auth` + `anonymous:devframe:auth:exchange` (the handshake) and `devframe:auth:revoke` (self-revoke) — register these on the RPC host if not passing the whole layer to `startHttpAndWs`. |
36+
| `authorize(methodName, session)` | The resolver gate: allows any `anonymous:`-prefixed method, otherwise requires `session.meta.isTrusted`. |
37+
| `onConnect(peer, session)` | Connect-time trust: reads a bearer off the peer's WS upgrade URL (`?devframe_auth_token=`) and trusts the session immediately when it's valid, before the client's own handshake call arrives. |
38+
| `printBanner()` | Prints the current code + magic-link URL. Safe to call repeatedly — it only prints once per code. |
39+
40+
## Using the pieces directly
41+
42+
Not using `startHttpAndWs`? Wire the same four pieces against your own transport:
43+
44+
```ts
45+
const auth = createInteractiveAuth(ctx)
46+
auth.rpcFunctions.forEach(fn => ctx.rpc.register(fn))
47+
auth.printBanner()
48+
49+
// in your resolver:
50+
if (!auth.authorize(methodName, session))
51+
throw new Error('not authorized')
52+
53+
// on each new WS peer:
54+
auth.onConnect(peer, session)
55+
```
56+
57+
Nothing here reaches into `devframe/node/hub-internals` — the recipe closes over the auth storage internally.

docs/plugins/terminals.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export default createTerminalsDevframe({
5050
})
5151
```
5252

53+
## Hub aggregation
54+
55+
Mounted into a hub, the plugin owns PTY/child-process spawning and its own streaming channel (`devframes-plugin-terminals:output`) — that's what the panel renders. It also mirrors every session it spawns into `ctx.terminals` (the hub's aggregate registry, streaming on `devframe:terminals`) so other tools — a launcher dock, a custom panel — see the same session list without depending on the plugin's own types. A session started the other way, via `ctx.terminals.startChildProcess` / `startPtySession` directly (e.g. from a launcher dock), shows up in the terminals panel too — the plugin reads foreign hub sessions read-only and renders them alongside its own, subscribing to the hub's channel for their output.
56+
57+
`ctx.terminals` is the source of truth for "what sessions exist"; the plugin is the panel that renders them and the one PTY-capable provider among possibly several session sources. The plugin never imports `@devframes/hub`'s types to stay mountable without a hub — it duck-types the minimal `register` / `update` / `events` shape it needs.
58+
5359
## Source
5460

5561
[`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals)

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"./node": "./dist/node/index.mjs",
3232
"./node/auth": "./dist/node/auth.mjs",
3333
"./node/hub-internals": "./dist/node/hub-internals.mjs",
34+
"./recipes/interactive-auth": "./dist/recipes/interactive-auth.mjs",
3435
"./recipes/open-helpers": "./dist/recipes/open-helpers.mjs",
3536
"./rpc": "./dist/rpc/index.mjs",
3637
"./rpc/client": "./dist/rpc/client.mjs",

packages/devframe/src/client/rpc-ws.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export function createWsRpcClientMode(
159159
async function requestTrustWithToken(token: string) {
160160
currentAuthToken = token
161161

162-
const result = await serverRpc.$call('devframe:anonymous:auth', {
162+
const result = await serverRpc.$call('anonymous:devframe:auth', {
163163
authToken: token,
164164
ua: describeUA(),
165165
origin: location.origin,
@@ -175,7 +175,7 @@ export function createWsRpcClientMode(
175175
}
176176

177177
async function requestTrustWithCode(code: string): Promise<string | null> {
178-
const result = await serverRpc.$call('devframe:auth:exchange', {
178+
const result = await serverRpc.$call('anonymous:devframe:auth:exchange', {
179179
code,
180180
ua: describeUA(),
181181
origin: location.origin,

packages/devframe/src/client/rpc.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ function persistAuthToken(token: string): void {
193193
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token
194194
}
195195

196+
/**
197+
* Clear any persisted devframe bearer token from this browser — the
198+
* counterpart to `persistAuthToken`. For integrations that manage their own
199+
* auth UI and just need to forget a token locally, e.g. after a sign-out
200+
* action, or after calling the server's `devframe:auth:revoke` to give up
201+
* the current session's token.
202+
*/
203+
export function forgetAuthToken(): void {
204+
try {
205+
localStorage.removeItem(CONNECTION_AUTH_TOKEN_KEY)
206+
}
207+
catch {}
208+
try {
209+
delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY]
210+
}
211+
catch {}
212+
}
213+
196214
function findConnectionMetaFromWindows(): ConnectionMeta | undefined {
197215
const getters = [
198216
() => (window as any)?.[CONNECTION_META_KEY],

0 commit comments

Comments
 (0)