You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
> 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.
- 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.
Copy file name to clipboardExpand all lines: docs/guide/hub.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -16,7 +16,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi
16
16
| Subsystem | Surface | Purpose |
17
17
|---|---|---|
18
18
|`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. |
Copy file name to clipboardExpand all lines: docs/guide/security.md
+33-10Lines changed: 33 additions & 10 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -12,34 +12,57 @@ An RPC handler runs with the full privileges of the process hosting it — files
12
12
13
13
Two postures cover that boundary:
14
14
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 adapter — e.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 banner — into a single `DevframeAuthHandler` you pass straight to `startHttpAndWs({ auth })`.
16
16
-**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.
17
17
18
18
> [!WARNING]
19
19
> `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.
20
20
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
+
21
27
## Authentication flow
22
28
23
29
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.
24
30
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`.
28
34
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.
30
36
31
37
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.
32
38
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.
const server =awaitstartHttpAndWs({ 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.
34
56
35
57
### Auth methods
36
58
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`).
38
60
39
61
| RPC method | Direction | Shape |
40
62
|------------|-----------|-------|
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 |
43
66
|`devframe:auth:revoked`| server → client | event — the connection's token was revoked |
|`buildOtpAuthUrl(origin, code?)`| build a magic-link URL embedding the code |
53
76
|`revokeAuthToken(context, storage, token)`| delete a token and disconnect any sessions using it |
54
77
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).
Copy file name to clipboardExpand all lines: docs/helpers/index.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -12,5 +12,6 @@ Helpers are the optional, opt-in surface around the core `defineDevframe` API: s
12
12
|[Vite Bridge](./vite-bridge)|`devframe/helpers/vite`| Vite plugin for mounting a devframe inside any Vite-based host (Astro, SolidStart, plain Vite). |
13
13
|[Nuxt Module](./nuxt)|`@devframes/nuxt`| Nuxt module that wires a Nuxt SPA as a devframe client and serves the dev-time RPC bridge. |
14
14
|[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. |
15
16
16
17
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.
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.
const server =awaitstartHttpAndWs({ 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:
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.
0 commit comments