@100xprompt/brain 0.0.0-stage → 0.1.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 +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
package/src/org/oidc.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// brain org: OpenID Connect primitives, used by the server (sso.ts: verify an ID token) and by the CLI (the browser sign-in flow).
|
|
2
|
+
//
|
|
3
|
+
// SERVER SIDE
|
|
4
|
+
// * discovery (`<issuer>/.well-known/openid-configuration`), cached; the document's `issuer` must equal the configured one;
|
|
5
|
+
// * JWKS, cached for an hour, refetched (at most every 10 s) when a token names a key id the cache does not know (key rotation);
|
|
6
|
+
// * verifyIdToken: RS256 / ES256 only (`alg: none`, HS256 and anything else are refused: a symmetric alg would let anyone who
|
|
7
|
+
// knows the public key forge a token), signature against the matching JWK, then `iss`, `aud` (+ `azp`), `exp`, `iat`, `nbf`,
|
|
8
|
+
// `nonce`. The caller decides about the email, the domain and the user.
|
|
9
|
+
// * every outgoing request has a timeout, refuses redirects, caps the response size, and requires https (plain http only to a
|
|
10
|
+
// loopback host, so a developer can run a local identity provider).
|
|
11
|
+
// Errors are `OidcError` with a short fixed `reason`; a reason NEVER contains any part of a token, a code or a response body.
|
|
12
|
+
// CLIENT SIDE
|
|
13
|
+
// * pkce(), buildAuthorizeUrl(), and ssoLoginFlow(): loopback listener on 127.0.0.1:<random port>, state check, one-shot.
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
15
|
+
|
|
16
|
+
export class OidcError extends Error {
|
|
17
|
+
constructor(readonly reason: string) {
|
|
18
|
+
super(reason)
|
|
19
|
+
this.name = "OidcError"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
24
|
+
|
|
25
|
+
export const MAX_JSON_BYTES = 256_000
|
|
26
|
+
export const MAX_ID_TOKEN_CHARS = 16_384
|
|
27
|
+
export const CLOCK_SKEW_SEC = 60
|
|
28
|
+
/** An ID token comes straight from the token endpoint: one issued long ago is not what a sign-in just produced. */
|
|
29
|
+
export const MAX_IAT_AGE_SEC = 600
|
|
30
|
+
|
|
31
|
+
export function isLoopbackHostname(host: string): boolean {
|
|
32
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase()
|
|
33
|
+
return h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An endpoint the server may call or the CLI may open: https, or http to loopback only. No credentials in the URL. */
|
|
37
|
+
export function endpointOk(raw: unknown): URL | undefined {
|
|
38
|
+
if (typeof raw !== "string" || raw.length > 2048) return undefined
|
|
39
|
+
let u: URL
|
|
40
|
+
try {
|
|
41
|
+
u = new URL(raw)
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
if (u.username || u.password || u.hash) return undefined
|
|
46
|
+
if (u.protocol === "https:") return u
|
|
47
|
+
if (u.protocol === "http:" && isLoopbackHostname(u.hostname)) return u
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- outgoing HTTP ---------------------------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export interface FetchOpts {
|
|
54
|
+
timeoutMs?: number
|
|
55
|
+
maxBytes?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One request, JSON answer, bounded in time and size. Any problem (network, status, size, content) is an OidcError(reason). */
|
|
59
|
+
export async function fetchJson(url: string, init: RequestInit, reason: string, o: FetchOpts = {}): Promise<unknown> {
|
|
60
|
+
if (!endpointOk(url)) throw new OidcError(`${reason}_url`)
|
|
61
|
+
const ctl = new AbortController()
|
|
62
|
+
const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? 8_000)
|
|
63
|
+
const max = o.maxBytes ?? MAX_JSON_BYTES
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { ...init, redirect: "error", signal: ctl.signal, headers: { accept: "application/json", ...(init.headers as Record<string, string> | undefined) } })
|
|
66
|
+
if (res.status !== 200) {
|
|
67
|
+
void res.body?.cancel().catch(() => {})
|
|
68
|
+
throw new OidcError(`${reason}_status`)
|
|
69
|
+
}
|
|
70
|
+
const declared = Number(res.headers.get("content-length") ?? "0")
|
|
71
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
72
|
+
void res.body?.cancel().catch(() => {})
|
|
73
|
+
throw new OidcError(`${reason}_too_large`)
|
|
74
|
+
}
|
|
75
|
+
const chunks: Uint8Array[] = []
|
|
76
|
+
let total = 0
|
|
77
|
+
if (res.body) {
|
|
78
|
+
const r = res.body.getReader()
|
|
79
|
+
for (;;) {
|
|
80
|
+
const { done, value } = await r.read()
|
|
81
|
+
if (done) break
|
|
82
|
+
total += value.byteLength
|
|
83
|
+
if (total > max) {
|
|
84
|
+
void r.cancel().catch(() => {})
|
|
85
|
+
throw new OidcError(`${reason}_too_large`)
|
|
86
|
+
}
|
|
87
|
+
chunks.push(value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
|
92
|
+
} catch {
|
|
93
|
+
throw new OidcError(`${reason}_garbled`)
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e instanceof OidcError) throw e
|
|
97
|
+
throw new OidcError(`${reason}_unreachable`)
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- discovery and keys ----------------------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface Discovery {
|
|
106
|
+
issuer: string
|
|
107
|
+
authorization_endpoint: string
|
|
108
|
+
token_endpoint: string
|
|
109
|
+
jwks_uri: string
|
|
110
|
+
/** Where the profile claims are when the provider keeps them out of the ID token (OIDC Core 5.3; the code flow often does). */
|
|
111
|
+
userinfo_endpoint?: string
|
|
112
|
+
token_endpoint_auth_methods_supported?: string[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const discoveryUrl = (issuer: string): string => `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`
|
|
116
|
+
|
|
117
|
+
export async function fetchDiscovery(issuer: string): Promise<Discovery> {
|
|
118
|
+
return checkDiscovery(issuer, await fetchJson(discoveryUrl(issuer), { method: "GET" }, "discovery"))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Checks a discovery document for `issuer` (exported so real providers' published documents can be tested offline). */
|
|
122
|
+
export function checkDiscovery(issuer: string, d: unknown): Discovery {
|
|
123
|
+
if (!isObj(d)) throw new OidcError("discovery_issuer")
|
|
124
|
+
// Multi-tenant endpoints (Microsoft's /common, /organizations) publish a template issuer: tokens then carry each tenant's own
|
|
125
|
+
// issuer, which no single configured issuer can match. The admin must configure the tenant's issuer instead.
|
|
126
|
+
if (typeof d.issuer === "string" && d.issuer.includes("{")) throw new OidcError("discovery_issuer_template")
|
|
127
|
+
// Accept a difference of a trailing slash only (Auth0 publishes "https://tenant.auth0.com/"); the provider's own spelling is
|
|
128
|
+
// then the issuer that ID tokens must carry.
|
|
129
|
+
if (typeof d.issuer !== "string" || d.issuer.replace(/\/+$/, "") !== issuer.replace(/\/+$/, "")) throw new OidcError("discovery_issuer")
|
|
130
|
+
const exact = d.issuer
|
|
131
|
+
if (Array.isArray(d.code_challenge_methods_supported) && !d.code_challenge_methods_supported.includes("S256")) throw new OidcError("discovery_no_pkce")
|
|
132
|
+
for (const k of ["authorization_endpoint", "token_endpoint", "jwks_uri"] as const) if (!endpointOk(d[k])) throw new OidcError("discovery_endpoint")
|
|
133
|
+
const userinfo = d.userinfo_endpoint === undefined ? undefined : endpointOk(d.userinfo_endpoint) ? (d.userinfo_endpoint as string) : undefined
|
|
134
|
+
const methods = Array.isArray(d.token_endpoint_auth_methods_supported) ? d.token_endpoint_auth_methods_supported.filter((m): m is string => typeof m === "string") : undefined
|
|
135
|
+
return {
|
|
136
|
+
issuer: exact,
|
|
137
|
+
authorization_endpoint: d.authorization_endpoint as string,
|
|
138
|
+
token_endpoint: d.token_endpoint as string,
|
|
139
|
+
jwks_uri: d.jwks_uri as string,
|
|
140
|
+
...(userinfo ? { userinfo_endpoint: userinfo } : {}),
|
|
141
|
+
...(methods ? { token_endpoint_auth_methods_supported: methods } : {}),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type TokenAuth = "client_secret_basic" | "client_secret_post"
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How a confidential client authenticates at the token endpoint. client_secret_basic is the default of OAuth 2.0 (RFC 6749 2.3.1:
|
|
149
|
+
* servers MUST support it) and of OpenID Connect registration, and the only method some providers accept for a client registered
|
|
150
|
+
* with it; client_secret_post when the admin asks for it, or when the provider says it supports post but not basic.
|
|
151
|
+
*/
|
|
152
|
+
export function tokenAuthFor(d: Discovery, wanted?: TokenAuth): TokenAuth {
|
|
153
|
+
if (wanted) return wanted
|
|
154
|
+
const m = d.token_endpoint_auth_methods_supported
|
|
155
|
+
if (m && !m.includes("client_secret_basic") && m.includes("client_secret_post")) return "client_secret_post"
|
|
156
|
+
return "client_secret_basic"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Authorization header for client_secret_basic: both parts form-urlencoded, then base64 (RFC 6749 2.3.1). */
|
|
160
|
+
export const basicAuth = (clientId: string, secret: string): string =>
|
|
161
|
+
`Basic ${Buffer.from(`${encodeURIComponent(clientId).replace(/%20/g, "+")}:${encodeURIComponent(secret).replace(/%20/g, "+")}`).toString("base64")}`
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether the provider says the email is verified: `email_verified` true (a boolean, or the string "true" that some providers put
|
|
165
|
+
* in userinfo). Microsoft Entra ID sends no email_verified; there an admin adds the optional claim `xms_edov` ("email domain owner
|
|
166
|
+
* verified"), accepted only when email_verified is absent.
|
|
167
|
+
*/
|
|
168
|
+
export function emailVerified(c: Record<string, unknown>): boolean {
|
|
169
|
+
const v = c.email_verified
|
|
170
|
+
if (v === true || v === "true") return true
|
|
171
|
+
if (v !== undefined) return false
|
|
172
|
+
const edov = c.xms_edov
|
|
173
|
+
return edov === true || edov === "true" || edov === 1 || edov === "1"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Jwk {
|
|
177
|
+
kty?: string
|
|
178
|
+
kid?: string
|
|
179
|
+
use?: string
|
|
180
|
+
alg?: string
|
|
181
|
+
n?: string
|
|
182
|
+
e?: string
|
|
183
|
+
crv?: string
|
|
184
|
+
x?: string
|
|
185
|
+
y?: string
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function fetchJwks(uri: string): Promise<Jwk[]> {
|
|
189
|
+
const d = await fetchJson(uri, { method: "GET" }, "jwks")
|
|
190
|
+
if (!isObj(d) || !Array.isArray(d.keys) || d.keys.length > 100) throw new OidcError("jwks_garbled")
|
|
191
|
+
return (d.keys as unknown[]).filter(isObj) as Jwk[]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Caches discovery and the key set. `now` is injectable (the server's clock) so rotation can be tested without waiting. */
|
|
195
|
+
export class KeyCache {
|
|
196
|
+
private disc?: { at: number; issuer: string; doc: Discovery }
|
|
197
|
+
private jwks?: { at: number; uri: string; keys: Jwk[] }
|
|
198
|
+
constructor(
|
|
199
|
+
private readonly now: () => number,
|
|
200
|
+
private readonly ttlMs = 3_600_000,
|
|
201
|
+
private readonly refetchCooldownMs = 10_000,
|
|
202
|
+
) {}
|
|
203
|
+
|
|
204
|
+
async discovery(issuer: string): Promise<Discovery> {
|
|
205
|
+
const t = this.now()
|
|
206
|
+
if (this.disc && this.disc.issuer === issuer && t - this.disc.at < this.ttlMs) return this.disc.doc
|
|
207
|
+
const doc = await fetchDiscovery(issuer)
|
|
208
|
+
this.disc = { at: t, issuer, doc }
|
|
209
|
+
return doc
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The keys for `uri`; `force` refetches (used on an unknown key id) unless one was fetched within the cooldown. */
|
|
213
|
+
async keys(uri: string, force = false): Promise<Jwk[]> {
|
|
214
|
+
const t = this.now()
|
|
215
|
+
const c = this.jwks
|
|
216
|
+
const fresh = c && c.uri === uri && t - c.at < this.ttlMs
|
|
217
|
+
if (fresh && !(force && t - c!.at >= this.refetchCooldownMs)) return c!.keys
|
|
218
|
+
const keys = await fetchJwks(uri)
|
|
219
|
+
this.jwks = { at: t, uri, keys }
|
|
220
|
+
return keys
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- JWT ---------------------------------------------------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
const B64URL = /^[A-Za-z0-9_-]*$/
|
|
227
|
+
const b64uDecode = (s: string): Buffer => {
|
|
228
|
+
if (!B64URL.test(s)) throw new OidcError("token_malformed")
|
|
229
|
+
return Buffer.from(s, "base64url")
|
|
230
|
+
}
|
|
231
|
+
const parsePart = (s: string): Record<string, unknown> => {
|
|
232
|
+
try {
|
|
233
|
+
const v = JSON.parse(b64uDecode(s).toString("utf8"))
|
|
234
|
+
if (!isObj(v)) throw new OidcError("token_malformed")
|
|
235
|
+
return v
|
|
236
|
+
} catch (e) {
|
|
237
|
+
throw e instanceof OidcError ? e : new OidcError("token_malformed")
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface IdClaims extends Record<string, unknown> {
|
|
242
|
+
iss: string
|
|
243
|
+
sub: string
|
|
244
|
+
email?: string
|
|
245
|
+
email_verified?: boolean
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function importKey(jwk: Jwk, alg: "RS256" | "ES256"): Promise<CryptoKey> {
|
|
249
|
+
if (alg === "RS256") {
|
|
250
|
+
if (jwk.kty !== "RSA" || typeof jwk.n !== "string" || typeof jwk.e !== "string") throw new OidcError("key_unusable")
|
|
251
|
+
if (b64uDecode(jwk.n).length < 256) throw new OidcError("key_too_weak") // under 2048 bits
|
|
252
|
+
return crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"])
|
|
253
|
+
}
|
|
254
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || typeof jwk.x !== "string" || typeof jwk.y !== "string") throw new OidcError("key_unusable")
|
|
255
|
+
return crypto.subtle.importKey("jwk", { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y, ext: true }, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"])
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface VerifyParams {
|
|
259
|
+
token: string
|
|
260
|
+
issuer: string
|
|
261
|
+
clientId: string
|
|
262
|
+
nonce: string
|
|
263
|
+
nowMs: number
|
|
264
|
+
/** Returns the JWKS; called again with force=true when the token's key id is unknown. */
|
|
265
|
+
keys: (force: boolean) => Promise<Jwk[]>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Verifies the ID token completely (signature and the standard claims) or throws OidcError. Never returns an unverified claim. */
|
|
269
|
+
export async function verifyIdToken(p: VerifyParams): Promise<IdClaims> {
|
|
270
|
+
if (typeof p.token !== "string" || p.token.length > MAX_ID_TOKEN_CHARS) throw new OidcError("token_malformed")
|
|
271
|
+
const parts = p.token.split(".")
|
|
272
|
+
if (parts.length !== 3 || !parts[0] || !parts[1]) throw new OidcError("token_malformed")
|
|
273
|
+
const header = parsePart(parts[0]!)
|
|
274
|
+
const alg = header.alg
|
|
275
|
+
if (alg !== "RS256" && alg !== "ES256") throw new OidcError("alg_not_allowed")
|
|
276
|
+
if (header.kid !== undefined && (typeof header.kid !== "string" || header.kid.length > 256)) throw new OidcError("token_malformed")
|
|
277
|
+
if (header.crit !== undefined) throw new OidcError("token_malformed")
|
|
278
|
+
const sig = b64uDecode(parts[2]!)
|
|
279
|
+
if (!sig.length) throw new OidcError("bad_signature")
|
|
280
|
+
if (alg === "ES256" && sig.length !== 64) throw new OidcError("bad_signature")
|
|
281
|
+
|
|
282
|
+
const kty = alg === "RS256" ? "RSA" : "EC"
|
|
283
|
+
const pick = (keys: Jwk[]): Jwk[] => keys.filter((k) => k.kty === kty && (k.use === undefined || k.use === "sig") && (k.alg === undefined || k.alg === alg) && (header.kid === undefined ? true : k.kid === header.kid))
|
|
284
|
+
let cands = pick(await p.keys(false))
|
|
285
|
+
if (!cands.length && header.kid !== undefined) cands = pick(await p.keys(true)) // rotation: look again, once
|
|
286
|
+
if (!cands.length) throw new OidcError("unknown_key")
|
|
287
|
+
if (header.kid === undefined && cands.length !== 1) throw new OidcError("unknown_key") // without a key id the key must be unambiguous
|
|
288
|
+
const signed = new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
|
|
289
|
+
let ok = false
|
|
290
|
+
for (const jwk of cands.slice(0, 5)) {
|
|
291
|
+
try {
|
|
292
|
+
const key = await importKey(jwk, alg)
|
|
293
|
+
ok = await crypto.subtle.verify(alg === "RS256" ? { name: "RSASSA-PKCS1-v1_5" } : { name: "ECDSA", hash: "SHA-256" }, key, new Uint8Array(sig), signed)
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (e instanceof OidcError && e.reason === "key_too_weak") throw e
|
|
296
|
+
ok = false
|
|
297
|
+
}
|
|
298
|
+
if (ok) break
|
|
299
|
+
}
|
|
300
|
+
if (!ok) throw new OidcError("bad_signature")
|
|
301
|
+
|
|
302
|
+
const c = parsePart(parts[1]!)
|
|
303
|
+
const nowSec = Math.floor(p.nowMs / 1000)
|
|
304
|
+
if (c.iss !== p.issuer) throw new OidcError("bad_issuer")
|
|
305
|
+
const aud = c.aud
|
|
306
|
+
const auds = typeof aud === "string" ? [aud] : Array.isArray(aud) && aud.every((a) => typeof a === "string") ? (aud as string[]) : []
|
|
307
|
+
if (!auds.includes(p.clientId)) throw new OidcError("bad_audience")
|
|
308
|
+
if (auds.length > 1 && c.azp !== p.clientId) throw new OidcError("bad_audience")
|
|
309
|
+
if (c.azp !== undefined && c.azp !== p.clientId) throw new OidcError("bad_audience")
|
|
310
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
311
|
+
const exp = num(c.exp)
|
|
312
|
+
const iat = num(c.iat)
|
|
313
|
+
if (exp === undefined || iat === undefined) throw new OidcError("missing_time_claims")
|
|
314
|
+
if (exp + CLOCK_SKEW_SEC <= nowSec) throw new OidcError("expired")
|
|
315
|
+
if (iat - CLOCK_SKEW_SEC > nowSec) throw new OidcError("issued_in_future")
|
|
316
|
+
if (iat + MAX_IAT_AGE_SEC + CLOCK_SKEW_SEC < nowSec) throw new OidcError("too_old")
|
|
317
|
+
if (c.nbf !== undefined && (num(c.nbf) === undefined || num(c.nbf)! - CLOCK_SKEW_SEC > nowSec)) throw new OidcError("not_yet_valid")
|
|
318
|
+
if (typeof c.nonce !== "string" || !safeEq(c.nonce, p.nonce)) throw new OidcError("bad_nonce")
|
|
319
|
+
if (typeof c.sub !== "string" || !c.sub) throw new OidcError("token_malformed")
|
|
320
|
+
return c as IdClaims
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function safeEq(a: string, b: string): boolean {
|
|
324
|
+
const x = createHash("sha256").update(a).digest()
|
|
325
|
+
const y = createHash("sha256").update(b).digest()
|
|
326
|
+
return x.equals(y) && a.length === b.length
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---- PKCE and the browser flow (client side) ------------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
export interface Pkce {
|
|
332
|
+
verifier: string
|
|
333
|
+
challenge: string
|
|
334
|
+
}
|
|
335
|
+
export function pkce(): Pkce {
|
|
336
|
+
const verifier = randomBytes(32).toString("base64url") // 43 characters
|
|
337
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") }
|
|
338
|
+
}
|
|
339
|
+
export const randomToken = (): string => randomBytes(32).toString("base64url")
|
|
340
|
+
|
|
341
|
+
export interface PublicSsoConfig {
|
|
342
|
+
enabled: boolean
|
|
343
|
+
issuer?: string
|
|
344
|
+
authorizationEndpoint?: string
|
|
345
|
+
clientId?: string
|
|
346
|
+
scopes?: string
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function buildAuthorizeUrl(c: { authorizationEndpoint: string; clientId: string; scopes: string; redirectUri: string; state: string; nonce: string; challenge: string }): string {
|
|
350
|
+
const u = new URL(c.authorizationEndpoint)
|
|
351
|
+
u.searchParams.set("response_type", "code")
|
|
352
|
+
u.searchParams.set("client_id", c.clientId)
|
|
353
|
+
u.searchParams.set("redirect_uri", c.redirectUri)
|
|
354
|
+
u.searchParams.set("scope", c.scopes)
|
|
355
|
+
u.searchParams.set("state", c.state)
|
|
356
|
+
u.searchParams.set("nonce", c.nonce)
|
|
357
|
+
u.searchParams.set("code_challenge", c.challenge)
|
|
358
|
+
u.searchParams.set("code_challenge_method", "S256")
|
|
359
|
+
return u.toString()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface SsoFlowDeps {
|
|
363
|
+
config(): Promise<PublicSsoConfig>
|
|
364
|
+
exchange(req: { code: string; codeVerifier: string; redirectUri: string; nonce: string }): Promise<{ token: string; user: unknown; expiresAt: string }>
|
|
365
|
+
/** Opens the URL in the person's browser (best effort); the URL is always printed as well. */
|
|
366
|
+
open?: (url: string) => void
|
|
367
|
+
print(line: string): void
|
|
368
|
+
timeoutMs?: number
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const PAGE = (msg: string): Response => new Response(`<!doctype html><meta charset="utf-8"><title>brain</title><p style="font:16px system-ui;margin:3em">${msg}</p>`, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } })
|
|
372
|
+
|
|
373
|
+
/** The whole browser sign-in. Returns what the server issued; throws Error with a message fit to print (never contains the token). */
|
|
374
|
+
export async function ssoLoginFlow(d: SsoFlowDeps): Promise<{ token: string; user: unknown; expiresAt: string }> {
|
|
375
|
+
const cfg = await d.config()
|
|
376
|
+
if (!cfg.enabled) throw new Error("single sign-on is not enabled on this server (ask an admin to run `brain org admin sso set`)")
|
|
377
|
+
const authz = endpointOk(cfg.authorizationEndpoint)
|
|
378
|
+
if (!authz || typeof cfg.clientId !== "string" || !cfg.clientId) throw new Error("the server sent an unusable sign-in configuration")
|
|
379
|
+
const scopes = typeof cfg.scopes === "string" && cfg.scopes ? cfg.scopes : "openid email profile"
|
|
380
|
+
const { verifier, challenge } = pkce()
|
|
381
|
+
const state = randomToken()
|
|
382
|
+
const nonce = randomToken()
|
|
383
|
+
|
|
384
|
+
let done!: (v: { code: string } | { error: string }) => void
|
|
385
|
+
const result = new Promise<{ code: string } | { error: string }>((r) => (done = r))
|
|
386
|
+
const listener = Bun.serve({
|
|
387
|
+
hostname: "127.0.0.1",
|
|
388
|
+
port: 0,
|
|
389
|
+
fetch(req) {
|
|
390
|
+
const u = new URL(req.url)
|
|
391
|
+
if (u.pathname !== "/callback" || req.method !== "GET") return new Response("not found", { status: 404 })
|
|
392
|
+
const given = u.searchParams.get("state") ?? ""
|
|
393
|
+
if (!given || !safeEq(given, state)) {
|
|
394
|
+
done({ error: "the sign-in answer did not match this request (state mismatch); nothing was stored" })
|
|
395
|
+
return PAGE("Sign-in failed: the answer did not match the request. You can close this tab.")
|
|
396
|
+
}
|
|
397
|
+
const err = u.searchParams.get("error")
|
|
398
|
+
if (err) {
|
|
399
|
+
done({ error: `the identity provider refused the sign-in (${err.replace(/[^A-Za-z0-9_ .-]/g, "").slice(0, 60) || "error"})` })
|
|
400
|
+
return PAGE("Sign-in was not completed. You can close this tab.")
|
|
401
|
+
}
|
|
402
|
+
const code = u.searchParams.get("code")
|
|
403
|
+
if (!code || code.length > 2048) {
|
|
404
|
+
done({ error: "the identity provider sent no authorization code" })
|
|
405
|
+
return PAGE("Sign-in failed. You can close this tab.")
|
|
406
|
+
}
|
|
407
|
+
done({ code })
|
|
408
|
+
return PAGE("Signed in. You can close this tab and return to the terminal.")
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
const timer = setTimeout(() => done({ error: "timed out waiting for the browser sign-in" }), d.timeoutMs ?? 300_000)
|
|
412
|
+
try {
|
|
413
|
+
const redirectUri = `http://127.0.0.1:${listener.port}/callback`
|
|
414
|
+
const url = buildAuthorizeUrl({ authorizationEndpoint: authz.toString(), clientId: cfg.clientId, scopes, redirectUri, state, nonce, challenge })
|
|
415
|
+
d.print("open this address in your browser to sign in:")
|
|
416
|
+
d.print(url)
|
|
417
|
+
try {
|
|
418
|
+
d.open?.(url)
|
|
419
|
+
} catch {}
|
|
420
|
+
const r = await result
|
|
421
|
+
if ("error" in r) throw new Error(r.error)
|
|
422
|
+
return await d.exchange({ code: r.code, codeVerifier: verifier, redirectUri, nonce })
|
|
423
|
+
} finally {
|
|
424
|
+
clearTimeout(timer)
|
|
425
|
+
void listener.stop() // graceful: the page the browser is being sent still arrives
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Best-effort browser launch. Failure is fine: the URL has been printed. */
|
|
430
|
+
export function openInBrowser(url: string): void {
|
|
431
|
+
const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url.replace(/&/g, "^&")] : ["xdg-open", url]
|
|
432
|
+
try {
|
|
433
|
+
Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref()
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|