@schlessera/brain-ui-server 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/agent/backend.d.ts +30 -0
- package/dist/agent/backend.d.ts.map +1 -0
- package/dist/agent/backend.js +226 -0
- package/dist/agent/backend.js.map +1 -0
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +116 -0
- package/dist/app.js.map +1 -0
- package/dist/brain/client.d.ts +43 -0
- package/dist/brain/client.d.ts.map +1 -0
- package/dist/brain/client.js +154 -0
- package/dist/brain/client.js.map +1 -0
- package/dist/brain/types.d.ts +41 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +2 -0
- package/dist/brain/types.js.map +1 -0
- package/dist/cron/scheduler.d.ts +9 -0
- package/dist/cron/scheduler.d.ts.map +1 -0
- package/dist/cron/scheduler.js +58 -0
- package/dist/cron/scheduler.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +73 -0
- package/dist/db/client.js.map +1 -0
- package/dist/files/walker.d.ts +63 -0
- package/dist/files/walker.d.ts.map +1 -0
- package/dist/files/walker.js +416 -0
- package/dist/files/walker.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +59 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +246 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/passkeys.d.ts +36 -0
- package/dist/middleware/passkeys.d.ts.map +1 -0
- package/dist/middleware/passkeys.js +427 -0
- package/dist/middleware/passkeys.js.map +1 -0
- package/dist/middleware/tailscale.d.ts +31 -0
- package/dist/middleware/tailscale.d.ts.map +1 -0
- package/dist/middleware/tailscale.js +64 -0
- package/dist/middleware/tailscale.js.map +1 -0
- package/dist/render/template.d.ts +7 -0
- package/dist/render/template.d.ts.map +1 -0
- package/dist/render/template.js +112 -0
- package/dist/render/template.js.map +1 -0
- package/dist/routes/brain.d.ts +155 -0
- package/dist/routes/brain.d.ts.map +1 -0
- package/dist/routes/brain.js +250 -0
- package/dist/routes/brain.js.map +1 -0
- package/dist/routes/files.d.ts +89 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +91 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/health.d.ts +37 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +27 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/providers.d.ts +33 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +9 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/render.d.ts +25 -0
- package/dist/routes/render.d.ts.map +1 -0
- package/dist/routes/render.js +47 -0
- package/dist/routes/render.js.map +1 -0
- package/dist/routes/sessions.d.ts +79 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +37 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/voice.d.ts +92 -0
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +64 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/voice/deepgram-token.d.ts +5 -0
- package/dist/voice/deepgram-token.d.ts.map +1 -0
- package/dist/voice/deepgram-token.js +63 -0
- package/dist/voice/deepgram-token.js.map +1 -0
- package/dist/voice/keyterm-builder.d.ts +18 -0
- package/dist/voice/keyterm-builder.d.ts.map +1 -0
- package/dist/voice/keyterm-builder.js +391 -0
- package/dist/voice/keyterm-builder.js.map +1 -0
- package/dist/voice/speech-providers.d.ts +25 -0
- package/dist/voice/speech-providers.d.ts.map +1 -0
- package/dist/voice/speech-providers.js +104 -0
- package/dist/voice/speech-providers.js.map +1 -0
- package/dist/ws/attachments.d.ts +10 -0
- package/dist/ws/attachments.d.ts.map +1 -0
- package/dist/ws/attachments.js +63 -0
- package/dist/ws/attachments.js.map +1 -0
- package/dist/ws/bridge.d.ts +7 -0
- package/dist/ws/bridge.d.ts.map +1 -0
- package/dist/ws/bridge.js +86 -0
- package/dist/ws/bridge.js.map +1 -0
- package/dist/ws/clients.d.ts +20 -0
- package/dist/ws/clients.d.ts.map +1 -0
- package/dist/ws/clients.js +48 -0
- package/dist/ws/clients.js.map +1 -0
- package/dist/ws/connection.d.ts +8 -0
- package/dist/ws/connection.d.ts.map +1 -0
- package/dist/ws/connection.js +116 -0
- package/dist/ws/connection.js.map +1 -0
- package/dist/ws/dispatch.d.ts +5 -0
- package/dist/ws/dispatch.d.ts.map +1 -0
- package/dist/ws/dispatch.js +139 -0
- package/dist/ws/dispatch.js.map +1 -0
- package/dist/ws/frames.d.ts +17 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/frames.js +37 -0
- package/dist/ws/frames.js.map +1 -0
- package/dist/ws/handler.d.ts +34 -0
- package/dist/ws/handler.d.ts.map +1 -0
- package/dist/ws/handler.js +33 -0
- package/dist/ws/handler.js.map +1 -0
- package/dist/ws/history.d.ts +17 -0
- package/dist/ws/history.d.ts.map +1 -0
- package/dist/ws/history.js +45 -0
- package/dist/ws/history.js.map +1 -0
- package/dist/ws/host.d.ts +40 -0
- package/dist/ws/host.d.ts.map +1 -0
- package/dist/ws/host.js +49 -0
- package/dist/ws/host.js.map +1 -0
- package/dist/ws/routing.d.ts +12 -0
- package/dist/ws/routing.d.ts.map +1 -0
- package/dist/ws/routing.js +31 -0
- package/dist/ws/routing.js.map +1 -0
- package/dist/ws/run-session.d.ts +17 -0
- package/dist/ws/run-session.d.ts.map +1 -0
- package/dist/ws/run-session.js +160 -0
- package/dist/ws/run-session.js.map +1 -0
- package/dist/ws/session-catalog.d.ts +25 -0
- package/dist/ws/session-catalog.d.ts.map +1 -0
- package/dist/ws/session-catalog.js +45 -0
- package/dist/ws/session-catalog.js.map +1 -0
- package/dist/ws/shrink.d.ts +34 -0
- package/dist/ws/shrink.d.ts.map +1 -0
- package/dist/ws/shrink.js +91 -0
- package/dist/ws/shrink.js.map +1 -0
- package/dist/ws/turns.d.ts +77 -0
- package/dist/ws/turns.d.ts.map +1 -0
- package/dist/ws/turns.js +89 -0
- package/dist/ws/turns.js.map +1 -0
- package/migrations/001_initial.sql +29 -0
- package/migrations/002_session_provider.sql +1 -0
- package/migrations/003_session_backend.sql +5 -0
- package/migrations/004_drop_gemini_backend.sql +20 -0
- package/migrations/005_passkey_credentials.sql +18 -0
- package/package.json +72 -0
- package/src/agent/backend.ts +308 -0
- package/src/app.ts +159 -0
- package/src/brain/client.ts +186 -0
- package/src/brain/types.ts +44 -0
- package/src/cron/scheduler.ts +80 -0
- package/src/db/client.ts +85 -0
- package/src/files/walker.ts +418 -0
- package/src/index.ts +39 -0
- package/src/middleware/auth.ts +321 -0
- package/src/middleware/passkeys.ts +510 -0
- package/src/middleware/tailscale.ts +81 -0
- package/src/render/template.ts +124 -0
- package/src/routes/brain.ts +305 -0
- package/src/routes/files.ts +99 -0
- package/src/routes/health.ts +29 -0
- package/src/routes/providers.ts +9 -0
- package/src/routes/render.ts +68 -0
- package/src/routes/sessions.ts +49 -0
- package/src/routes/voice.ts +80 -0
- package/src/voice/deepgram-token.ts +80 -0
- package/src/voice/keyterm-builder.ts +426 -0
- package/src/voice/speech-providers.ts +111 -0
- package/src/ws/attachments.ts +86 -0
- package/src/ws/bridge.ts +139 -0
- package/src/ws/clients.ts +57 -0
- package/src/ws/connection.ts +132 -0
- package/src/ws/dispatch.ts +162 -0
- package/src/ws/frames.ts +42 -0
- package/src/ws/handler.ts +50 -0
- package/src/ws/history.ts +51 -0
- package/src/ws/host.ts +69 -0
- package/src/ws/routing.ts +43 -0
- package/src/ws/run-session.ts +192 -0
- package/src/ws/session-catalog.ts +97 -0
- package/src/ws/shrink.ts +95 -0
- package/src/ws/turns.ts +151 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import {
|
|
4
|
+
generateAuthenticationOptions,
|
|
5
|
+
generateRegistrationOptions,
|
|
6
|
+
verifyAuthenticationResponse as realVerifyAuthentication,
|
|
7
|
+
verifyRegistrationResponse as realVerifyRegistration,
|
|
8
|
+
} from "@simplewebauthn/server";
|
|
9
|
+
import type {
|
|
10
|
+
AuthenticationResponseJSON,
|
|
11
|
+
RegistrationResponseJSON,
|
|
12
|
+
WebAuthnCredential,
|
|
13
|
+
} from "@simplewebauthn/server";
|
|
14
|
+
import {
|
|
15
|
+
type AuthMode,
|
|
16
|
+
issueSessionCookie,
|
|
17
|
+
consumeLoginToken,
|
|
18
|
+
LOGIN_RATE_LIMIT,
|
|
19
|
+
GLOBAL_LOGIN_RATE_LIMIT,
|
|
20
|
+
} from "./auth.js";
|
|
21
|
+
import { clientIp } from "./tailscale.js";
|
|
22
|
+
import { getDb } from "../db/client.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WebAuthn passkeys as an extension of `password` mode: the password bootstraps
|
|
26
|
+
* the first registration (and stays as recovery), passkeys are the day-to-day
|
|
27
|
+
* login. A successful assertion mints the same session cookie as password login
|
|
28
|
+
* (issueSessionCookie), so authGuard / isWsAuthorized / TTL are untouched.
|
|
29
|
+
*
|
|
30
|
+
* Self-contained on purpose — this module (plus its auth.ts imports) is slated
|
|
31
|
+
* to move wholesale into @schlessera/ui-server.
|
|
32
|
+
*
|
|
33
|
+
* Credentials are RP-scoped: a credential registered on localhost does not
|
|
34
|
+
* exist for the production domain and vice versa. Rows carry rp_id so all
|
|
35
|
+
* environments share the table.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const CHALLENGE_TTL_MS = 120_000; // > the 60s ceremony timeout
|
|
39
|
+
// Pending challenges are unauthenticated state; cap and evict oldest-first so
|
|
40
|
+
// login-options spam can't grow memory. Losing one costs a retried ceremony.
|
|
41
|
+
const MAX_PENDING_CHALLENGES = 100;
|
|
42
|
+
const OPTIONS_RATE_LIMIT = 10; // login-options per IP per minute (own bucket)
|
|
43
|
+
// Branding/identity are config-injected with the historical values as
|
|
44
|
+
// defaults, so existing deployments keep their credentials.
|
|
45
|
+
function rpName(): string {
|
|
46
|
+
return process.env.WEBAUTHN_RP_NAME || "Brain UI";
|
|
47
|
+
}
|
|
48
|
+
// The single "user" every passkey belongs to. Stable across registrations so
|
|
49
|
+
// an authenticator overwrites its existing entry instead of stacking
|
|
50
|
+
// duplicates for the same site.
|
|
51
|
+
//
|
|
52
|
+
// WARNING: the user handle is part of the WebAuthn wire contract — it is
|
|
53
|
+
// burned into every resident credential at registration. Changing
|
|
54
|
+
// WEBAUTHN_USER_ID on a deployment that already has passkeys orphans them
|
|
55
|
+
// (authenticators will present a handle the server no longer recognizes as
|
|
56
|
+
// the same user and re-registration stacks a second entry). Set it before the
|
|
57
|
+
// first passkey is registered and never change it.
|
|
58
|
+
function userName(): string {
|
|
59
|
+
return process.env.WEBAUTHN_USER_NAME || "owner";
|
|
60
|
+
}
|
|
61
|
+
/** WebAuthn caps `user.id` at 64 bytes; browsers reject anything longer. */
|
|
62
|
+
const MAX_USER_ID_BYTES = 64;
|
|
63
|
+
|
|
64
|
+
function userId(): Uint8Array<ArrayBuffer> {
|
|
65
|
+
return new TextEncoder().encode(
|
|
66
|
+
process.env.WEBAUTHN_USER_ID || "brain-ui-owner"
|
|
67
|
+
) as Uint8Array<ArrayBuffer>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Refuse to boot on a WebAuthn user handle the browser would reject. Called
|
|
72
|
+
* from createApp(): an oversized handle otherwise produces registration
|
|
73
|
+
* options that look fine server-side and fail silently in every browser.
|
|
74
|
+
*/
|
|
75
|
+
export function assertPasskeyConfig(): void {
|
|
76
|
+
const bytes = userId().byteLength;
|
|
77
|
+
if (bytes > MAX_USER_ID_BYTES) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`WEBAUTHN_USER_ID is ${bytes} bytes; WebAuthn allows at most ${MAX_USER_ID_BYTES}. ` +
|
|
80
|
+
"Pick a shorter stable identifier (it is burned into every credential)."
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// ES256 + RS256 only: every mainstream authenticator supports ES256, and it
|
|
85
|
+
// keeps Ed25519 (whose WebCrypto verify path on Bun is unproven) out of the
|
|
86
|
+
// database. Widen once Bun's Ed25519 verify is validated.
|
|
87
|
+
const SUPPORTED_ALGORITHM_IDS = [-7, -257];
|
|
88
|
+
|
|
89
|
+
type ChallengeType = "registration" | "authentication";
|
|
90
|
+
|
|
91
|
+
/** Injectable seams for tests; production callers pass nothing. */
|
|
92
|
+
export interface PasskeyDeps {
|
|
93
|
+
verifyRegistrationResponse?: typeof realVerifyRegistration;
|
|
94
|
+
verifyAuthenticationResponse?: typeof realVerifyAuthentication;
|
|
95
|
+
now?: () => number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- challenge store (in-memory: single-process server; move to a shared
|
|
99
|
+
// store if this ever runs multi-process) ---
|
|
100
|
+
|
|
101
|
+
const pendingChallenges = new Map<string, { type: ChallengeType; expiresAt: number }>();
|
|
102
|
+
|
|
103
|
+
function putChallenge(challenge: string, type: ChallengeType, now: number): void {
|
|
104
|
+
for (const [key, entry] of pendingChallenges) {
|
|
105
|
+
if (entry.expiresAt <= now) pendingChallenges.delete(key);
|
|
106
|
+
}
|
|
107
|
+
while (pendingChallenges.size >= MAX_PENDING_CHALLENGES) {
|
|
108
|
+
const oldest = pendingChallenges.keys().next().value;
|
|
109
|
+
if (oldest === undefined) break;
|
|
110
|
+
pendingChallenges.delete(oldest);
|
|
111
|
+
}
|
|
112
|
+
pendingChallenges.set(challenge, { type, expiresAt: now + CHALLENGE_TTL_MS });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Single-use: deleted before verification, so a failed verify still burns it. */
|
|
116
|
+
function consumeChallenge(challenge: string, type: ChallengeType, now: number): boolean {
|
|
117
|
+
const entry = pendingChallenges.get(challenge);
|
|
118
|
+
if (!entry) return false;
|
|
119
|
+
pendingChallenges.delete(challenge);
|
|
120
|
+
return entry.type === type && entry.expiresAt > now;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The challenge the browser actually signed, from clientDataJSON. */
|
|
124
|
+
function challengeFromResponse(response: {
|
|
125
|
+
response: { clientDataJSON: string };
|
|
126
|
+
}): string | null {
|
|
127
|
+
try {
|
|
128
|
+
const json = JSON.parse(
|
|
129
|
+
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
|
|
130
|
+
) as { challenge?: unknown };
|
|
131
|
+
return typeof json.challenge === "string" ? json.challenge : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- RP / origin resolution ---
|
|
138
|
+
|
|
139
|
+
function envList(name: string): string[] {
|
|
140
|
+
return (process.env[name] ?? "")
|
|
141
|
+
.split(",")
|
|
142
|
+
.map((s) => s.trim())
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
147
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Derive the relying party from the request instead of demanding new env:
|
|
152
|
+
* same-origin deployments, split topology (ALLOWED_ORIGINS), and dev all work
|
|
153
|
+
* unconfigured. WEBAUTHN_ORIGINS / WEBAUTHN_RP_ID remain as overrides for
|
|
154
|
+
* proxies that rewrite Host.
|
|
155
|
+
*/
|
|
156
|
+
function resolveRp(c: Context): { rpID: string; origin: string } | null {
|
|
157
|
+
const origin = c.req.header("origin");
|
|
158
|
+
if (!origin) return null;
|
|
159
|
+
|
|
160
|
+
let url: URL;
|
|
161
|
+
try {
|
|
162
|
+
url = new URL(origin);
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const allowed =
|
|
168
|
+
envList("WEBAUTHN_ORIGINS").includes(origin) ||
|
|
169
|
+
envList("ALLOWED_ORIGINS").includes(origin) ||
|
|
170
|
+
url.host === c.req.header("host") ||
|
|
171
|
+
// Dev escape hatch: the vite proxy sets changeOrigin, so the server sees
|
|
172
|
+
// Host localhost:3000 but Origin http://localhost:5173. Opt-in via an
|
|
173
|
+
// explicit flag — NOT NODE_ENV, whose absence must never widen the RP.
|
|
174
|
+
(process.env.BRAIN_UI_ALLOW_LOOPBACK_ORIGIN === "1" &&
|
|
175
|
+
isLoopbackHostname(url.hostname));
|
|
176
|
+
if (!allowed) return null;
|
|
177
|
+
|
|
178
|
+
return { rpID: process.env.WEBAUTHN_RP_ID ?? url.hostname, origin };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** rpID for endpoints where same-origin GETs legitimately omit Origin. */
|
|
182
|
+
function rpIdForRead(c: Context): string {
|
|
183
|
+
if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
|
|
184
|
+
const origin = c.req.header("origin");
|
|
185
|
+
if (origin) {
|
|
186
|
+
try {
|
|
187
|
+
return new URL(origin).hostname;
|
|
188
|
+
} catch {
|
|
189
|
+
// fall through to Host
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const host = c.req.header("host") ?? "localhost";
|
|
193
|
+
return host.replace(/:\d+$/, "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- persistence ---
|
|
197
|
+
|
|
198
|
+
interface CredentialRow {
|
|
199
|
+
id: string;
|
|
200
|
+
public_key: Uint8Array;
|
|
201
|
+
counter: number;
|
|
202
|
+
transports: string | null;
|
|
203
|
+
rp_id: string;
|
|
204
|
+
aaguid: string | null;
|
|
205
|
+
device_type: string | null;
|
|
206
|
+
backed_up: number;
|
|
207
|
+
label: string;
|
|
208
|
+
created_at: number;
|
|
209
|
+
last_used_at: number | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Wire shape shared with the client (shared/protocol.ts PasskeySummary). */
|
|
213
|
+
function toSummary(row: CredentialRow) {
|
|
214
|
+
return {
|
|
215
|
+
id: row.id,
|
|
216
|
+
label: row.label,
|
|
217
|
+
rpId: row.rp_id,
|
|
218
|
+
createdAt: row.created_at,
|
|
219
|
+
lastUsedAt: row.last_used_at,
|
|
220
|
+
backedUp: row.backed_up === 1,
|
|
221
|
+
deviceType: row.device_type,
|
|
222
|
+
transports: row.transports ? (JSON.parse(row.transports) as string[]) : null,
|
|
223
|
+
aaguid: row.aaguid,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function credentialsForRp(rpID: string): CredentialRow[] {
|
|
228
|
+
return getDb()
|
|
229
|
+
.query("SELECT * FROM passkey_credentials WHERE rp_id = ?")
|
|
230
|
+
.all(rpID) as CredentialRow[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function credentialById(id: string): CredentialRow | null {
|
|
234
|
+
return getDb()
|
|
235
|
+
.query("SELECT * FROM passkey_credentials WHERE id = ?")
|
|
236
|
+
.get(id) as CredentialRow | null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function toWebAuthnCredential(row: CredentialRow): WebAuthnCredential {
|
|
240
|
+
return {
|
|
241
|
+
id: row.id,
|
|
242
|
+
publicKey: new Uint8Array(row.public_key),
|
|
243
|
+
counter: row.counter,
|
|
244
|
+
transports: row.transports
|
|
245
|
+
? (JSON.parse(row.transports) as WebAuthnCredential["transports"])
|
|
246
|
+
: undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sanitizeLabel(label: unknown): string {
|
|
251
|
+
return typeof label === "string" ? label.trim().slice(0, 64) : "";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function notEnabled(c: Context) {
|
|
255
|
+
return c.json({ error: "Passkey login is not enabled" }, 400);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Once a passkey exists for the request's RP, password login is disabled —
|
|
260
|
+
* the password becomes registration-bootstrap and break-glass recovery only
|
|
261
|
+
* (set BRAIN_UI_ALLOW_PASSWORD=1 to re-enable it, e.g. after losing every
|
|
262
|
+
* authenticator). Scoped per RP so e.g. localhost dev keeps password login
|
|
263
|
+
* until it has its own credential. Host-derived rpID is fine here: a forged
|
|
264
|
+
* Host can at most re-enable password auth, which still requires the password.
|
|
265
|
+
*/
|
|
266
|
+
export function passwordLoginDisabled(c: Context): boolean {
|
|
267
|
+
if (process.env.BRAIN_UI_ALLOW_PASSWORD === "1") return false;
|
|
268
|
+
const row = getDb()
|
|
269
|
+
.query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
|
|
270
|
+
.get(rpIdForRead(c)) as { n: number };
|
|
271
|
+
return row.n > 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Unauthenticated passkey routes. Mount BEFORE the auth guard, next to
|
|
276
|
+
* authRoutes.
|
|
277
|
+
*/
|
|
278
|
+
export function passkeyPublicRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
|
|
279
|
+
const verifyAuthentication = deps.verifyAuthenticationResponse ?? realVerifyAuthentication;
|
|
280
|
+
const now = deps.now ?? Date.now;
|
|
281
|
+
const app = new Hono();
|
|
282
|
+
|
|
283
|
+
// Capability hint for the login screen: which methods can it offer?
|
|
284
|
+
app.get("/auth/methods", (c) => {
|
|
285
|
+
c.header("Cache-Control", "no-store");
|
|
286
|
+
if (mode !== "password") {
|
|
287
|
+
return c.json({ password: false, passkey: false });
|
|
288
|
+
}
|
|
289
|
+
const row = getDb()
|
|
290
|
+
.query("SELECT COUNT(*) AS n FROM passkey_credentials WHERE rp_id = ?")
|
|
291
|
+
.get(rpIdForRead(c)) as { n: number };
|
|
292
|
+
return c.json({ password: !passwordLoginDisabled(c), passkey: row.n > 0 });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.post("/auth/passkey/login-options", async (c) => {
|
|
296
|
+
if (mode !== "password") return notEnabled(c);
|
|
297
|
+
const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
|
|
298
|
+
// Own bucket: conditional-UI mounts fire this on every login-screen load
|
|
299
|
+
// and must not starve real verify attempts of their shared budget.
|
|
300
|
+
if (!consumeLoginToken(`pk-opt:${ip}`, OPTIONS_RATE_LIMIT)) {
|
|
301
|
+
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
302
|
+
}
|
|
303
|
+
const rp = resolveRp(c);
|
|
304
|
+
if (!rp) return c.json({ error: "Origin not allowed" }, 400);
|
|
305
|
+
|
|
306
|
+
// Empty allowCredentials + discoverable credentials = usernameless login.
|
|
307
|
+
const options = await generateAuthenticationOptions({
|
|
308
|
+
rpID: rp.rpID,
|
|
309
|
+
allowCredentials: [],
|
|
310
|
+
userVerification: "required",
|
|
311
|
+
});
|
|
312
|
+
putChallenge(options.challenge, "authentication", now());
|
|
313
|
+
return c.json(options);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.post("/auth/passkey/login-verify", async (c) => {
|
|
317
|
+
if (mode !== "password") return notEnabled(c);
|
|
318
|
+
const ip = clientIp(c, process.env.TRUST_PROXY === "1") || "unknown";
|
|
319
|
+
// Same buckets as password login: one combined online-guess budget.
|
|
320
|
+
const perIpOk = consumeLoginToken(`ip:${ip}`, LOGIN_RATE_LIMIT);
|
|
321
|
+
const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
|
|
322
|
+
if (!perIpOk || !globalOk) {
|
|
323
|
+
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
324
|
+
}
|
|
325
|
+
const rp = resolveRp(c);
|
|
326
|
+
if (!rp) return c.json({ error: "Origin not allowed" }, 400);
|
|
327
|
+
|
|
328
|
+
let response: AuthenticationResponseJSON;
|
|
329
|
+
try {
|
|
330
|
+
response = await c.req.json();
|
|
331
|
+
} catch {
|
|
332
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const fail = () => c.json({ error: "Passkey verification failed" }, 401);
|
|
336
|
+
|
|
337
|
+
const row = typeof response?.id === "string" ? credentialById(response.id) : null;
|
|
338
|
+
if (!row || row.rp_id !== rp.rpID) return fail();
|
|
339
|
+
|
|
340
|
+
const challenge = challengeFromResponse(response);
|
|
341
|
+
if (!challenge || !consumeChallenge(challenge, "authentication", now())) {
|
|
342
|
+
return fail();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const result = await verifyAuthentication({
|
|
347
|
+
response,
|
|
348
|
+
expectedChallenge: challenge,
|
|
349
|
+
expectedOrigin: rp.origin,
|
|
350
|
+
expectedRPID: rp.rpID,
|
|
351
|
+
credential: toWebAuthnCredential(row),
|
|
352
|
+
});
|
|
353
|
+
if (!result.verified) return fail();
|
|
354
|
+
getDb()
|
|
355
|
+
.prepare(
|
|
356
|
+
"UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
|
|
357
|
+
)
|
|
358
|
+
.run(result.authenticationInfo.newCounter, now(), row.id);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
361
|
+
// The library throws on a counter regression — possible cloned
|
|
362
|
+
// credential. Cloud passkeys legitimately sit at 0, so warn, don't
|
|
363
|
+
// revoke.
|
|
364
|
+
if (/counter/i.test(message)) {
|
|
365
|
+
console.warn(
|
|
366
|
+
`[passkeys] counter regression for credential ${row.id} — possible cloned credential`
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
console.warn(`[passkeys] authentication failed: ${message}`);
|
|
370
|
+
}
|
|
371
|
+
return fail();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await issueSessionCookie(c);
|
|
375
|
+
return c.json({ ok: true });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return app;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Session-gated passkey routes. Mount AFTER the auth guard — gating comes from
|
|
383
|
+
* mount position, not per-route checks.
|
|
384
|
+
*/
|
|
385
|
+
export function passkeyManagementRoutes(mode: AuthMode, deps: PasskeyDeps = {}): Hono {
|
|
386
|
+
const verifyRegistration = deps.verifyRegistrationResponse ?? realVerifyRegistration;
|
|
387
|
+
const now = deps.now ?? Date.now;
|
|
388
|
+
const app = new Hono();
|
|
389
|
+
|
|
390
|
+
app.post("/auth/passkey/register-options", async (c) => {
|
|
391
|
+
if (mode !== "password") return notEnabled(c);
|
|
392
|
+
const rp = resolveRp(c);
|
|
393
|
+
if (!rp) return c.json({ error: "Origin not allowed" }, 400);
|
|
394
|
+
|
|
395
|
+
const options = await generateRegistrationOptions({
|
|
396
|
+
rpName: rpName(),
|
|
397
|
+
rpID: rp.rpID,
|
|
398
|
+
userName: userName(),
|
|
399
|
+
userID: userId(),
|
|
400
|
+
userDisplayName: rpName(),
|
|
401
|
+
attestationType: "none",
|
|
402
|
+
excludeCredentials: credentialsForRp(rp.rpID).map((row) => ({
|
|
403
|
+
id: row.id,
|
|
404
|
+
transports: toWebAuthnCredential(row).transports,
|
|
405
|
+
})),
|
|
406
|
+
authenticatorSelection: {
|
|
407
|
+
residentKey: "required",
|
|
408
|
+
userVerification: "required",
|
|
409
|
+
},
|
|
410
|
+
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
|
|
411
|
+
});
|
|
412
|
+
putChallenge(options.challenge, "registration", now());
|
|
413
|
+
return c.json(options);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
app.post("/auth/passkey/register-verify", async (c) => {
|
|
417
|
+
if (mode !== "password") return notEnabled(c);
|
|
418
|
+
const rp = resolveRp(c);
|
|
419
|
+
if (!rp) return c.json({ error: "Origin not allowed" }, 400);
|
|
420
|
+
|
|
421
|
+
let body: { response?: RegistrationResponseJSON; label?: unknown };
|
|
422
|
+
try {
|
|
423
|
+
body = await c.req.json();
|
|
424
|
+
} catch {
|
|
425
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
426
|
+
}
|
|
427
|
+
if (!body.response) return c.json({ error: "Invalid request body" }, 400);
|
|
428
|
+
|
|
429
|
+
const challenge = challengeFromResponse(body.response);
|
|
430
|
+
if (!challenge || !consumeChallenge(challenge, "registration", now())) {
|
|
431
|
+
return c.json({ error: "Passkey registration failed" }, 400);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const result = await verifyRegistration({
|
|
436
|
+
response: body.response,
|
|
437
|
+
expectedChallenge: challenge,
|
|
438
|
+
expectedOrigin: rp.origin,
|
|
439
|
+
expectedRPID: rp.rpID,
|
|
440
|
+
});
|
|
441
|
+
if (!result.verified || !result.registrationInfo) {
|
|
442
|
+
return c.json({ error: "Passkey registration failed" }, 400);
|
|
443
|
+
}
|
|
444
|
+
const { credential, aaguid, credentialDeviceType, credentialBackedUp } =
|
|
445
|
+
result.registrationInfo;
|
|
446
|
+
const createdAt = now();
|
|
447
|
+
getDb()
|
|
448
|
+
.prepare(
|
|
449
|
+
`INSERT INTO passkey_credentials
|
|
450
|
+
(id, public_key, counter, transports, rp_id, aaguid, device_type,
|
|
451
|
+
backed_up, label, created_at)
|
|
452
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
453
|
+
)
|
|
454
|
+
.run(
|
|
455
|
+
credential.id,
|
|
456
|
+
credential.publicKey,
|
|
457
|
+
credential.counter,
|
|
458
|
+
credential.transports ? JSON.stringify(credential.transports) : null,
|
|
459
|
+
rp.rpID,
|
|
460
|
+
aaguid || null,
|
|
461
|
+
credentialDeviceType,
|
|
462
|
+
credentialBackedUp ? 1 : 0,
|
|
463
|
+
sanitizeLabel(body.label),
|
|
464
|
+
createdAt
|
|
465
|
+
);
|
|
466
|
+
const row = credentialById(credential.id);
|
|
467
|
+
return c.json({ ok: true, credential: row ? toSummary(row) : null });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
470
|
+
console.warn(`[passkeys] registration failed: ${message}`);
|
|
471
|
+
return c.json({ error: "Passkey registration failed" }, 400);
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// All rows, not rpID-filtered: the prod panel must be able to show and
|
|
476
|
+
// delete stale localhost/dev credentials. The client badges foreign RPs.
|
|
477
|
+
app.get("/auth/passkey/list", (c) => {
|
|
478
|
+
if (mode !== "password") return notEnabled(c);
|
|
479
|
+
const rows = getDb()
|
|
480
|
+
.query("SELECT * FROM passkey_credentials ORDER BY created_at DESC")
|
|
481
|
+
.all() as CredentialRow[];
|
|
482
|
+
return c.json({ credentials: rows.map(toSummary) });
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
app.put("/auth/passkey/:id", async (c) => {
|
|
486
|
+
if (mode !== "password") return notEnabled(c);
|
|
487
|
+
let body: { label?: unknown };
|
|
488
|
+
try {
|
|
489
|
+
body = await c.req.json();
|
|
490
|
+
} catch {
|
|
491
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
492
|
+
}
|
|
493
|
+
const result = getDb()
|
|
494
|
+
.prepare("UPDATE passkey_credentials SET label = ? WHERE id = ?")
|
|
495
|
+
.run(sanitizeLabel(body.label), c.req.param("id"));
|
|
496
|
+
if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
|
|
497
|
+
return c.json({ ok: true });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
app.delete("/auth/passkey/:id", (c) => {
|
|
501
|
+
if (mode !== "password") return notEnabled(c);
|
|
502
|
+
const result = getDb()
|
|
503
|
+
.prepare("DELETE FROM passkey_credentials WHERE id = ?")
|
|
504
|
+
.run(c.req.param("id"));
|
|
505
|
+
if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
|
|
506
|
+
return c.json({ ok: true });
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
return app;
|
|
510
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import { getConnInfo } from "hono/bun";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Restrict access to Tailscale VPN clients (CGNAT range 100.64.0.0/10) or
|
|
6
|
+
* localhost.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: header trust is OFF by default. An exposed port lets anyone spoof
|
|
9
|
+
* `x-forwarded-for`, so the real socket address is used unless `TRUST_PROXY=1`
|
|
10
|
+
* — set that ONLY when a trusted reverse proxy in front of brain-ui sets the
|
|
11
|
+
* forwarding header.
|
|
12
|
+
*/
|
|
13
|
+
export interface TailscaleGuardOptions {
|
|
14
|
+
/** Trust x-forwarded-for / x-real-ip. Defaults to `TRUST_PROXY === "1"`. */
|
|
15
|
+
trustProxy?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function tailscaleGuard(
|
|
19
|
+
options: TailscaleGuardOptions = {}
|
|
20
|
+
): MiddlewareHandler {
|
|
21
|
+
const trustProxy = options.trustProxy ?? process.env.TRUST_PROXY === "1";
|
|
22
|
+
return async (c, next) => {
|
|
23
|
+
if (isTailscaleAllowed(c, trustProxy)) {
|
|
24
|
+
await next();
|
|
25
|
+
} else {
|
|
26
|
+
return c.json({ error: "VPN access required" }, 403);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when the request's client IP is a Tailscale or loopback address. */
|
|
32
|
+
export function isTailscaleAllowed(c: Context, trustProxy: boolean): boolean {
|
|
33
|
+
const ip = clientIp(c, trustProxy);
|
|
34
|
+
return isTailscaleIp(ip) || isLocalIp(ip);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The client IP. With `trustProxy`, read the forwarding header a trusted proxy
|
|
39
|
+
* set; otherwise use the real socket address (unspoofable). Returns "" when the
|
|
40
|
+
* source is unavailable (e.g. Hono's `app.request()` has no socket) — callers
|
|
41
|
+
* treat "" as not-allowed.
|
|
42
|
+
*
|
|
43
|
+
* X-Forwarded-For is parsed RIGHT-TO-LEFT: each proxy appends the address it saw,
|
|
44
|
+
* so the rightmost entries are the trusted hops we control and the leftmost is
|
|
45
|
+
* client-controllable. `TRUST_PROXY_HOPS` (default 1) is how many proxies front
|
|
46
|
+
* this app; the client IP is the entry just before them. Taking the leftmost
|
|
47
|
+
* entry (the old behaviour) let a client spoof its address by pre-seeding XFF.
|
|
48
|
+
*/
|
|
49
|
+
export function clientIp(c: Context, trustProxy: boolean): string {
|
|
50
|
+
if (trustProxy) {
|
|
51
|
+
const forwarded = c.req.header("x-forwarded-for");
|
|
52
|
+
if (forwarded) {
|
|
53
|
+
const parts = forwarded
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((part) => part.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (parts.length > 0) {
|
|
58
|
+
const hops = Math.max(1, Number(process.env.TRUST_PROXY_HOPS) || 1);
|
|
59
|
+
const ip = parts[Math.max(0, parts.length - hops)];
|
|
60
|
+
if (ip) return ip;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return c.req.header("x-real-ip") ?? "";
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return getConnInfo(c).remote.address ?? "";
|
|
67
|
+
} catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isTailscaleIp(ip: string): boolean {
|
|
73
|
+
// Tailscale CGNAT range: 100.64.0.0/10 (100.64.0.0 - 100.127.255.255)
|
|
74
|
+
if (!ip.startsWith("100.")) return false;
|
|
75
|
+
const second = parseInt(ip.split(".")[1], 10);
|
|
76
|
+
return second >= 64 && second <= 127;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isLocalIp(ip: string): boolean {
|
|
80
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "localhost";
|
|
81
|
+
}
|