@100xprompt/brain 0.0.0-stage → 0.1.1

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.
Files changed (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -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
+ }