okengine 0.17.2 → 0.18.3

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 (197) hide show
  1. package/README.md +9 -5
  2. package/manifest.v1.schema.json +61 -2
  3. package/package.json +18 -4
  4. package/site/content/docs/elements/clock.mdx +1 -1
  5. package/site/content/docs/elements/flow.mdx +25 -1
  6. package/site/content/docs/elements/store.mdx +287 -341
  7. package/site/content/docs/elements/vault.mdx +5 -5
  8. package/site/content/docs/get-started/installation.mdx +1 -2
  9. package/site/content/docs/get-started/introduction.mdx +54 -110
  10. package/site/content/docs/get-started/meta.json +9 -1
  11. package/site/content/docs/get-started/testing.mdx +328 -0
  12. package/site/content/docs/get-started/why.mdx +94 -70
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/plugins/apple.mdx +151 -0
  15. package/site/content/docs/plugins/discord.mdx +139 -0
  16. package/site/content/docs/plugins/facebook.mdx +134 -0
  17. package/site/content/docs/plugins/figma.mdx +138 -0
  18. package/site/content/docs/plugins/github.mdx +138 -0
  19. package/site/content/docs/plugins/google.mdx +153 -0
  20. package/site/content/docs/plugins/index.mdx +47 -1
  21. package/site/content/docs/plugins/meta.json +10 -0
  22. package/site/content/docs/plugins/microsoft.mdx +151 -0
  23. package/site/content/docs/plugins/oauth.mdx +188 -0
  24. package/site/content/docs/plugins/x.mdx +125 -0
  25. package/site/content/docs/reference/cli.md +3 -2
  26. package/site/content/docs/reference/client.mdx +58 -1
  27. package/site/content/docs/reference/configuration.mdx +2 -4
  28. package/site/content/docs/reference/fx.mdx +3 -1
  29. package/site/content/docs/reference/index.mdx +0 -5
  30. package/site/content/docs/reference/meta.json +2 -2
  31. package/site/content/docs/reference/okid.mdx +137 -0
  32. package/src/auth/bindings.ts +1 -1
  33. package/src/auth/config.ts +9 -0
  34. package/src/auth/identity-sql.ts +314 -0
  35. package/src/auth/identity.ts +140 -2
  36. package/src/auth/index.ts +17 -1
  37. package/src/auth/method-context.ts +3 -0
  38. package/src/auth/oauth-as/cimd.ts +132 -0
  39. package/src/auth/oauth-as/crypto.test.ts +101 -0
  40. package/src/auth/oauth-as/crypto.ts +393 -0
  41. package/src/auth/oauth-as/errors.ts +68 -0
  42. package/src/auth/oauth-as/http.test.ts +419 -0
  43. package/src/auth/oauth-as/http.ts +842 -0
  44. package/src/auth/oauth-as/stores.ts +61 -0
  45. package/src/auth/oauth-as/tables.ts +142 -0
  46. package/src/auth/tables.ts +0 -11
  47. package/src/bench/README.md +83 -0
  48. package/src/bench/REPORT.md +176 -0
  49. package/src/bench/g01-rls-stamp.bench.ts +194 -0
  50. package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
  51. package/src/bench/g03-signal-once.bench.ts +157 -0
  52. package/src/bench/g03-signal-reconnect.bench.ts +254 -0
  53. package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
  54. package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
  55. package/src/bench/g05-sustained-full.bench.ts +265 -0
  56. package/src/bench/g06-mixed-load.bench.ts +260 -0
  57. package/src/bench/g07-vault-crypto.bench.ts +100 -0
  58. package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
  59. package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
  60. package/src/bench/g08-store-kv-durable.bench.ts +133 -0
  61. package/src/bench/g08-store-sql.bench.ts +178 -0
  62. package/src/bench/g09-journal-sustained.bench.ts +203 -0
  63. package/src/bench/g10-observability-contention.bench.ts +246 -0
  64. package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
  65. package/src/bench/g13-elements.bench.ts +427 -0
  66. package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
  67. package/src/bench/g15-postgres-degradation.bench.ts +264 -0
  68. package/src/bench/g16-live-query-fanout.bench.ts +206 -0
  69. package/src/bench/lib/event-loop-lag.ts +26 -0
  70. package/src/bench/lib/infra.ts +60 -0
  71. package/src/bench/lib/report.ts +52 -0
  72. package/src/bench/lib/rss-sampler.ts +61 -0
  73. package/src/bench/lib/signal-pg.ts +88 -0
  74. package/src/bench/load-app.ts +337 -0
  75. package/src/bench/load-child.ts +108 -0
  76. package/src/bench/smoke.bench.ts +43 -0
  77. package/src/cli/competitor-mention-removal.test.ts +28 -0
  78. package/src/cli/doctor-fd.ts +117 -0
  79. package/src/cli/doctor.test.ts +192 -0
  80. package/src/cli/doctor.ts +129 -1
  81. package/src/client/create.ts +95 -1
  82. package/src/client/index.ts +9 -2
  83. package/src/client/transport.ts +11 -4
  84. package/src/client/use-live-query.ts +154 -0
  85. package/src/client-react/index.ts +15 -1
  86. package/src/client-react/live-resource.ts +246 -0
  87. package/src/client-react/use-live-query.test.ts +475 -0
  88. package/src/client-react/use-live-query.ts +530 -0
  89. package/src/compiler/extract.test.ts +518 -0
  90. package/src/compiler/extract.ts +386 -19
  91. package/src/console/server/invoke-user-flow.ts +2 -1
  92. package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
  95. package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
  96. package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
  97. package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
  98. package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
  99. package/src/console/ui-next/dist/index.html +1 -1
  100. package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
  101. package/src/drivers/cdc-outbox.ts +389 -0
  102. package/src/drivers/memory.ts +20 -0
  103. package/src/drivers/oauth-apple.ts +156 -0
  104. package/src/drivers/oauth-discord.ts +79 -0
  105. package/src/drivers/oauth-facebook.ts +80 -0
  106. package/src/drivers/oauth-figma.ts +116 -0
  107. package/src/drivers/oauth-github.ts +92 -0
  108. package/src/drivers/oauth-google.ts +142 -0
  109. package/src/drivers/oauth-microsoft.ts +174 -0
  110. package/src/drivers/oauth-oidc.ts +293 -0
  111. package/src/drivers/oauth-shared.ts +326 -0
  112. package/src/drivers/oauth-types.ts +159 -0
  113. package/src/drivers/oauth-x.ts +77 -0
  114. package/src/drivers/oauth2-common.ts +95 -0
  115. package/src/drivers/oauth2-token.ts +61 -0
  116. package/src/drivers/pg-rls-row-passes.ts +251 -0
  117. package/src/drivers/pg-rls.ts +2 -0
  118. package/src/drivers/postgres.ts +45 -2
  119. package/src/drivers/signal-postgres.ts +2 -1
  120. package/src/elements/channel/runtime.ts +29 -2
  121. package/src/elements/channel.test.ts +52 -0
  122. package/src/elements/gate/boot.ts +29 -2
  123. package/src/elements/store/emit-drizzle.ts +147 -14
  124. package/src/elements/store/field-ddl.test.ts +118 -0
  125. package/src/elements/store/field-types.test.ts +455 -0
  126. package/src/elements/store/list-query.golden.json +777 -0
  127. package/src/elements/store/list-query.parity.test.ts +396 -0
  128. package/src/elements/store/list-query.ts +792 -0
  129. package/src/elements/store/live-default.test.ts +136 -0
  130. package/src/elements/store/live-http.test.ts +160 -0
  131. package/src/elements/store/live-isolation.test.ts +291 -0
  132. package/src/elements/store/live-query-runtime.test.ts +323 -0
  133. package/src/elements/store/live-query-runtime.ts +403 -0
  134. package/src/elements/store/live-query-server.test.ts +377 -0
  135. package/src/elements/store/live-query-server.ts +102 -0
  136. package/src/elements/store/live-query.ts +97 -0
  137. package/src/elements/store/resource.ts +189 -680
  138. package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
  139. package/src/elements/store/schema-decl.ts +539 -41
  140. package/src/elements/store/sql-rls-stamp.test.ts +27 -0
  141. package/src/elements/store/sql-session.ts +297 -35
  142. package/src/elements/store/table.ts +102 -21
  143. package/src/elements/store.test.ts +3 -1
  144. package/src/elements/store.ts +12 -1
  145. package/src/elements/vault/chaos-child.ts +74 -1
  146. package/src/elements/vault/chaos.test.ts +4 -2
  147. package/src/elements/vault/storage.ts +4 -2
  148. package/src/index.ts +4 -1
  149. package/src/kernel/app-auth.ts +1 -0
  150. package/src/kernel/app.ts +116 -2
  151. package/src/kernel/auth-sharing.test.ts +196 -0
  152. package/src/kernel/boot.test.ts +3 -3
  153. package/src/kernel/errors.ts +8 -0
  154. package/src/kernel/fx.test.ts +1 -0
  155. package/src/kernel/fx.ts +14 -2
  156. package/src/kernel/horizontal-child.ts +2 -1
  157. package/src/kernel/http-resource.ts +33 -7
  158. package/src/kernel/identity-host-persist.test.ts +119 -0
  159. package/src/kernel/instance-id.ts +4 -2
  160. package/src/kernel/journal.ts +2 -1
  161. package/src/kernel/mcp-tool.test.ts +95 -0
  162. package/src/kernel/on.ts +9 -0
  163. package/src/kernel/realtime-bind.ts +326 -0
  164. package/src/kernel/resource-live.ts +117 -0
  165. package/src/kernel/triggers.ts +86 -4
  166. package/src/manifest/diff.ts +37 -0
  167. package/src/manifest/types.ts +64 -2
  168. package/src/okid.bench.test.ts +64 -0
  169. package/src/okid.test.ts +338 -0
  170. package/src/okid.ts +245 -0
  171. package/src/plugins/anonymous.ts +19 -1
  172. package/src/plugins/auth/shared.ts +15 -0
  173. package/src/plugins/index.ts +2 -0
  174. package/src/plugins/magic-link.ts +10 -8
  175. package/src/plugins/mcp-oauth.ts +208 -0
  176. package/src/plugins/oauth/flow-store.ts +117 -0
  177. package/src/plugins/oauth/link.ts +69 -0
  178. package/src/plugins/oauth/shared.ts +108 -0
  179. package/src/plugins/oauth/token-vault.ts +100 -0
  180. package/src/plugins/oauth.security.test.ts +535 -0
  181. package/src/plugins/oauth.ts +532 -0
  182. package/src/plugins/otp.ts +48 -6
  183. package/src/plugins/passkey.ts +20 -1
  184. package/src/plugins/two-factor.ts +11 -0
  185. package/src/plugins/username.ts +40 -7
  186. package/src/release/build-lib.ts +7 -1
  187. package/src/release/measure.ts +1 -0
  188. package/src/release/official-plugins.ts +4 -1
  189. package/src/runs/collect.ts +2 -1
  190. package/src/runs/drivers/files.ts +2 -1
  191. package/src/test/create-test-app.ts +114 -5
  192. package/src/test/export-bundle.test.ts +33 -0
  193. package/src/test/live-signals.test.ts +83 -0
  194. package/src/test/tenant-isolation.test.ts +175 -0
  195. package/src/testing.ts +26 -0
  196. package/src/upgrade/codemods.ts +1 -1
  197. package/site/content/docs/reference/migrating-environments.mdx +0 -158
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Shared OIDC machinery: discovery caching, ID token verification
3
+ * (signature → iss → aud/azp → exp → nonce) and the ES256 client-secret JWT
4
+ * Apple requires. Hand-rolled on `crypto.subtle`.
5
+ */
6
+
7
+ import { OAuthProtocolError, type OAuthDriverId } from "./oauth-types.ts";
8
+ import {
9
+ base64UrlEncode,
10
+ fetchJwks,
11
+ generateState,
12
+ parseJwt,
13
+ selectJwksKey,
14
+ verifyJwtSignature,
15
+ } from "./oauth-shared.ts";
16
+
17
+ /** Force a JWKS re-fetch bypassing the cache. */
18
+ async function fetchJwksFresh(
19
+ url: string,
20
+ fetchFn: typeof globalThis.fetch,
21
+ ): Promise<ReadonlyArray<Record<string, unknown>>> {
22
+ const { clearJwksCache } = await import("./oauth-shared.ts");
23
+ clearJwksCache(url);
24
+ return fetchJwks(url, fetchFn);
25
+ }
26
+
27
+ /** Cached OpenID discovery document. */
28
+ interface DiscoveryCacheEntry {
29
+ readonly authorizationEndpoint: string;
30
+ readonly tokenEndpoint: string;
31
+ readonly jwksUri: string;
32
+ readonly fetchedAt: number;
33
+ }
34
+
35
+ const globalDiscoveryCache = new Map<string, DiscoveryCacheEntry>();
36
+ const DISCOVERY_TTL_MS = 60 * 60_000;
37
+
38
+ /**
39
+ * Fetch (and cache) `/.well-known/openid-configuration` endpoints.
40
+ *
41
+ * @param issuer - Issuer / discovery origin (Microsoft `{tenantid}` templates allowed)
42
+ * @param fetchFn - Injectable fetch
43
+ */
44
+ export async function discoverOpenId(
45
+ issuer: string,
46
+ fetchFn: typeof globalThis.fetch,
47
+ ): Promise<{ authorizationEndpoint: string; tokenEndpoint: string; jwksUri: string }> {
48
+ const cached = globalDiscoveryCache.get(issuer);
49
+ if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) return cached;
50
+ const wellKnown = `${issuer.endsWith("/") ? issuer : `${issuer}/`}.well-known/openid-configuration`;
51
+ const res = await fetchFn(wellKnown, { headers: { accept: "application/json" } });
52
+ if (!res.ok) {
53
+ throw new OAuthProtocolError(
54
+ "discovery_failed",
55
+ `OIDC discovery failed with HTTP ${res.status}`,
56
+ );
57
+ }
58
+ const doc = (await res.json()) as Record<string, unknown>;
59
+ const authorizationEndpoint = doc["authorization_endpoint"];
60
+ const tokenEndpoint = doc["token_endpoint"];
61
+ const jwksUri = doc["jwks_uri"];
62
+ if (
63
+ typeof authorizationEndpoint !== "string" ||
64
+ typeof tokenEndpoint !== "string" ||
65
+ typeof jwksUri !== "string"
66
+ ) {
67
+ throw new OAuthProtocolError(
68
+ "discovery_failed",
69
+ "Discovery document missing required endpoints",
70
+ );
71
+ }
72
+ const entry = { authorizationEndpoint, tokenEndpoint, jwksUri, fetchedAt: Date.now() };
73
+ globalDiscoveryCache.set(issuer, entry);
74
+ return entry;
75
+ }
76
+
77
+ /** Verified ID-token claims. */
78
+ export interface VerifiedClaims {
79
+ readonly header: Record<string, unknown>;
80
+ readonly payload: Record<string, unknown>;
81
+ /** Token `iss`. */
82
+ readonly issuer: string;
83
+ /** Token subject. */
84
+ readonly subject: string;
85
+ }
86
+
87
+ /**
88
+ * Verify an OIDC ID token end-to-end and return its claims.
89
+ *
90
+ * Order matters: signature first, then issuer equality against the flow's
91
+ * stored expectation (RFC 9207 mix-up defense), then audience, expiry, nonce.
92
+ *
93
+ * @param options - Token + expectations + JWKS location
94
+ */
95
+ export async function verifyIdToken(options: {
96
+ readonly idToken: string;
97
+ readonly jwksUri: string;
98
+ readonly expectedIssuer: string;
99
+ readonly expectedAudience: string;
100
+ readonly expectedNonce?: string;
101
+ readonly fetchFn: typeof globalThis.fetch;
102
+ readonly now?: () => number;
103
+ }): Promise<VerifiedClaims> {
104
+ const parsed = parseJwt(options.idToken);
105
+ const attempt = async (keys: ReadonlyArray<Record<string, unknown>>): Promise<void> => {
106
+ const key = selectJwksKey(keys, parsed.header["kid"]);
107
+ await verifyJwtSignature(parsed, key);
108
+ };
109
+ try {
110
+ await attempt(await fetchJwks(options.jwksUri, options.fetchFn));
111
+ } catch (err) {
112
+ // Key-rotation window: one forced refresh on an unknown kid.
113
+ if (err instanceof OAuthProtocolError && err.reason === "unknown_kid") {
114
+ await attempt(await fetchJwksFresh(options.jwksUri, options.fetchFn));
115
+ } else {
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ const iss = parsed.payload["iss"];
121
+ if (typeof iss !== "string" || iss !== options.expectedIssuer) {
122
+ throw new OAuthProtocolError(
123
+ "issuer_mismatch",
124
+ `ID token iss ${String(iss)} does not match the initiated provider`,
125
+ );
126
+ }
127
+ assertAudience(parsed.payload, options.expectedAudience);
128
+
129
+ const now = (options.now ?? (() => Date.now()))();
130
+ const exp = parsed.payload["exp"];
131
+ if (typeof exp !== "number" || now >= exp * 1000) {
132
+ throw new OAuthProtocolError("expired_id_token", "ID token expired");
133
+ }
134
+
135
+ if (options.expectedNonce !== undefined) {
136
+ const nonce = parsed.payload["nonce"];
137
+ if (nonce !== options.expectedNonce) {
138
+ throw new OAuthProtocolError("nonce_mismatch", "ID token nonce does not match");
139
+ }
140
+ }
141
+
142
+ const sub = parsed.payload["sub"];
143
+ if (typeof sub !== "string" || sub.length === 0) {
144
+ throw new OAuthProtocolError("malformed_id_token", "ID token missing sub claim");
145
+ }
146
+ return { header: parsed.header, payload: parsed.payload, issuer: iss, subject: sub };
147
+ }
148
+
149
+ /**
150
+ * Audience check per OIDC Core: `aud` must contain the client id; when it
151
+ * lists multiple audiences an explicit matching `azp` is required.
152
+ *
153
+ * @param payload - ID token claims
154
+ * @param clientId - Expected client audience
155
+ */
156
+ export function assertAudience(payload: Record<string, unknown>, clientId: string): void {
157
+ const aud = payload["aud"];
158
+ const auds = Array.isArray(aud) ? aud.map(String) : typeof aud === "string" ? [aud] : [];
159
+ if (!auds.includes(clientId)) {
160
+ throw new OAuthProtocolError("aud_mismatch", "ID token aud does not include this client");
161
+ }
162
+ if (auds.length > 1) {
163
+ const azp = payload["azp"];
164
+ if (azp !== clientId) {
165
+ throw new OAuthProtocolError("azp_mismatch", "Multi-audience ID token missing azp=client_id");
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Mint a fresh Apple client-secret JWT (ES256, 6-month ceiling — we use 1h).
172
+ *
173
+ * @param options - Private key PEM (PKCS#8), team/key/client ids, clock
174
+ */
175
+ export async function createAppleClientSecretJwt(options: {
176
+ readonly privateKeyPem: string;
177
+ readonly teamId: string;
178
+ readonly keyId: string;
179
+ readonly clientId: string;
180
+ readonly now?: () => number;
181
+ }): Promise<string> {
182
+ const now = Math.floor((options.now ?? (() => Date.now()))() / 1000);
183
+ const pkcs8 = parsePemBody(options.privateKeyPem);
184
+ const key = await crypto.subtle.importKey(
185
+ "pkcs8",
186
+ pkcs8,
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" } as any,
189
+ false,
190
+ ["sign"],
191
+ );
192
+ const enc = new TextEncoder();
193
+ const header = base64UrlEncode(enc.encode(JSON.stringify({ alg: "ES256", kid: options.keyId })));
194
+ const payload = base64UrlEncode(
195
+ enc.encode(
196
+ JSON.stringify({
197
+ iss: options.teamId,
198
+ iat: now,
199
+ exp: now + 3600,
200
+ aud: "https://appleid.apple.com",
201
+ sub: options.clientId,
202
+ }),
203
+ ),
204
+ );
205
+ const derSig = new Uint8Array(
206
+ await crypto.subtle.sign(
207
+ { name: "ECDSA", hash: "SHA-256" },
208
+ key,
209
+ enc.encode(`${header}.${payload}`),
210
+ ),
211
+ );
212
+ // Bun's WebCrypto emits the raw P-1363 form (r‖s) — already JOSE-shaped.
213
+ const joseSig = rawToJose(derSig.subarray(0, 32), derSig.subarray(32));
214
+ return `${header}.${payload}.${joseSig}`;
215
+ }
216
+
217
+ function parsePemBody(pem: string): ArrayBuffer {
218
+ const body = pem
219
+ .replaceAll("-----BEGIN PRIVATE KEY-----", "")
220
+ .replaceAll("-----END PRIVATE KEY-----", "")
221
+ .replace(/\s+/g, "");
222
+ const binary = atob(body);
223
+ const bytes = new Uint8Array(binary.length);
224
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
225
+ return bytes.buffer as ArrayBuffer;
226
+ }
227
+
228
+ /** Convert ASN.1 DER ECDSA sig to JOSE fixed-width 64-byte form. */
229
+ export function derToJose(der: Uint8Array): string {
230
+ let offset = 2;
231
+ if (der[0] !== 0x30) throw new OAuthProtocolError("client_secret_jwt", "Bad DER sequence");
232
+ const readInt = (): Uint8Array => {
233
+ if (der[offset] !== 0x02) throw new OAuthProtocolError("client_secret_jwt", "Bad DER integer");
234
+ const len = der[offset + 1]!;
235
+ const value = der.subarray(offset + 2, offset + 2 + len);
236
+ offset += 2 + len;
237
+ return value;
238
+ };
239
+ const r = stripLeadingZero(readInt());
240
+ const s = stripLeadingZero(readInt());
241
+ const pad = (v: Uint8Array): Uint8Array => {
242
+ const out = new Uint8Array(32);
243
+ out.set(v.length > 32 ? v.slice(v.length - 32) : v, 32 - Math.min(v.length, 32));
244
+ return out;
245
+ };
246
+ return encodeJose(pad(r), pad(s));
247
+ }
248
+
249
+ /**
250
+ * Wrap two raw P-1363 ECDSA integers into the JOSE fixed-width form
251
+ * (Bun's WebCrypto already signs in this shape — no DER involved).
252
+ *
253
+ * @param r - Left 32-byte half
254
+ * @param s - Right 32-byte half
255
+ */
256
+ export function rawToJose(r: Uint8Array, s: Uint8Array): string {
257
+ return encodeJose(pad32(r), pad32(s));
258
+ }
259
+
260
+ function pad32(value: Uint8Array): Uint8Array {
261
+ const trimmed = trimLeadingZeros(value);
262
+ const out = new Uint8Array(32);
263
+ out.set(trimmed.slice(0, 32), Math.max(32 - trimmed.length, 0));
264
+ return out;
265
+ }
266
+
267
+ function trimLeadingZeros(value: Uint8Array): Uint8Array {
268
+ let start = 0;
269
+ while (start < value.length - 1 && value[start] === 0) start++;
270
+ return value.subarray(start);
271
+ }
272
+
273
+ function encodeJose(r: Uint8Array, s: Uint8Array): string {
274
+ const jose = new Uint8Array(64);
275
+ jose.set(r, 0);
276
+ jose.set(s, 32);
277
+ let binary = "";
278
+ for (const b of jose) binary += String.fromCharCode(b);
279
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
280
+ }
281
+
282
+ function stripLeadingZero(value: Uint8Array): Uint8Array {
283
+ return value.length > 1 && value[0] === 0 ? value.subarray(1) : value;
284
+ }
285
+
286
+ /**
287
+ * Fresh state/nonce pair for an OIDC start.
288
+ */
289
+ export function mintOidcFlowSecrets(): { state: string; nonce: string } {
290
+ return { state: generateState(), nonce: generateState() };
291
+ }
292
+
293
+ export type { OAuthDriverId };
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Hand-rolled OAuth/OIDC primitives — `crypto.subtle` only, no external
3
+ * library (locked decision). Mirrors the MCP/Gate precedent: injectable
4
+ * fetch, explicit errors, strict parsing.
5
+ */
6
+
7
+ import { OAuthProtocolError, type OAuthAuthorizeInput, type OAuthDriverId } from "./oauth-types.ts";
8
+
9
+ /** Parsed JWT parts. */
10
+ export interface ParsedJwt {
11
+ readonly header: Record<string, unknown>;
12
+ readonly payload: Record<string, unknown>;
13
+ /** base64url signature segment. */
14
+ readonly signature: string;
15
+ /** Signed segments (`header.payload`) for `crypto.subtle.verify`. */
16
+ readonly signedPart: string;
17
+ }
18
+
19
+ /**
20
+ * Parse a compact JWS without verifying — verification is always explicit.
21
+ * Rejects non-JWT shapes and embedded JSON web tokens.
22
+ *
23
+ * @param token - Compact JWT
24
+ */
25
+ export function parseJwt(token: string): ParsedJwt {
26
+ const parts = token.split(".");
27
+ if (parts.length !== 3) {
28
+ throw new OAuthProtocolError("malformed_id_token", "ID token is not a compact JWS");
29
+ }
30
+ const header = decodeSegment<Record<string, unknown>>(parts[0]!);
31
+ const payload = decodeSegment<Record<string, unknown>>(parts[1]!);
32
+ return { header, payload, signature: parts[2]!, signedPart: `${parts[0]}.${parts[1]}` };
33
+ }
34
+
35
+ function decodeSegment<T>(segment: string): T {
36
+ const bytes = base64UrlDecode(segment);
37
+ try {
38
+ return JSON.parse(new TextDecoder().decode(bytes)) as T;
39
+ } catch {
40
+ throw new OAuthProtocolError("malformed_id_token", "JWT segment is not valid JSON");
41
+ }
42
+ }
43
+
44
+ /** Decode a base64url string to bytes. */
45
+ export function base64UrlDecode(input: string): Uint8Array {
46
+ const padded = input.replaceAll("-", "+").replaceAll("_", "/");
47
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
48
+ const out = new Uint8Array(binary.length);
49
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
50
+ return out;
51
+ }
52
+
53
+ /** Encode bytes to base64url without padding. */
54
+ export function base64UrlEncode(bytes: Uint8Array): string {
55
+ let binary = "";
56
+ for (const byte of bytes) binary += String.fromCharCode(byte);
57
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
58
+ }
59
+
60
+ /**
61
+ * Generate a PKCE code verifier: 43–128 chars from the RFC 7636 alphabet
62
+ * (`A–Z a–z 0–9 -._~`), ~96 chars of entropy here.
63
+ */
64
+ export function generateCodeVerifier(): string {
65
+ return randomUrlSafe(72);
66
+ }
67
+
68
+ /** Generate a URL-safe opaque token (~192 bits entropy). */
69
+ export function generateState(): string {
70
+ return randomUrlSafe(24);
71
+ }
72
+
73
+ function randomUrlSafe(bytes: number): string {
74
+ const raw = new Uint8Array(bytes);
75
+ crypto.getRandomValues(raw);
76
+ // Map to the unreserved alphabet; 64 symbols per byte keeps full entropy.
77
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
78
+ let out = "";
79
+ for (const b of raw) out += alphabet[b % 64]!;
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Derive the S256 PKCE challenge for a verifier.
85
+ *
86
+ * @param verifier - Code verifier
87
+ */
88
+ export async function codeChallengeS256(verifier: string): Promise<string> {
89
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
90
+ return base64UrlEncode(new Uint8Array(digest));
91
+ }
92
+
93
+ /**
94
+ * Verify an RS256 / ES256 JWT signature against a JWK.
95
+ *
96
+ * @param parsed - Pre-parsed token
97
+ * @param jwk - Public key from the provider JWKS
98
+ */
99
+ export async function verifyJwtSignature(
100
+ parsed: ParsedJwt,
101
+ jwk: Record<string, unknown>,
102
+ ): Promise<void> {
103
+ const alg = typeof jwk.alg === "string" ? jwk.alg : parsed.header["alg"];
104
+ if (alg !== "RS256" && alg !== "ES256") {
105
+ throw new OAuthProtocolError("unsupported_alg", `Unsupported ID token alg: ${String(alg)}`);
106
+ }
107
+ const key = await importJwk(jwk, alg);
108
+ // WebCrypto ECDSA consumes the IEEE P-1363 raw form — which is exactly the
109
+ // JOSE wire format, so RS256-style DER conversion never applies here.
110
+ const signatureBytes = base64UrlDecode(parsed.signature);
111
+ const ok = await crypto.subtle.verify(
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ (alg === "ES256" ? { name: "ECDSA", hash: "SHA-256" } : "RSASSA-PKCS1-v1_5") as any,
114
+ key,
115
+ toArrayBuffer(signatureBytes),
116
+ toArrayBuffer(new TextEncoder().encode(parsed.signedPart)),
117
+ );
118
+ if (!ok) {
119
+ throw new OAuthProtocolError("bad_signature", "ID token signature verification failed");
120
+ }
121
+ }
122
+
123
+ async function importJwk(jwk: Record<string, unknown>, alg: "RS256" | "ES256"): Promise<CryptoKey> {
124
+ const normalized =
125
+ alg === "ES256"
126
+ ? { kty: "EC", crv: "P-256", x: jwk["x"], y: jwk["y"] }
127
+ : { kty: "RSA", n: jwk["n"], e: jwk["e"] };
128
+ const format = "jwk" as const;
129
+ return crypto.subtle.importKey(
130
+ format,
131
+ normalized as unknown as {
132
+ kty: string;
133
+ crv?: string;
134
+ x?: string;
135
+ y?: string;
136
+ n?: string;
137
+ e?: string;
138
+ },
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
+ (alg === "ES256"
141
+ ? { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }
142
+ : { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }) as any,
143
+ false,
144
+ ["verify"],
145
+ );
146
+ }
147
+
148
+ /** Copy a view into a fresh ArrayBuffer for WebCrypto BufferSource typing. */
149
+ export function toArrayBuffer(view: Uint8Array): ArrayBuffer {
150
+ const copy = new Uint8Array(view.byteLength);
151
+ copy.set(view);
152
+ return copy.buffer;
153
+ }
154
+
155
+ /**
156
+ * Fetch and cache a provider JWKS document.
157
+ *
158
+ * @param url - JWKS endpoint
159
+ * @param fetchFn - Injectable fetch
160
+ * @param cache - Process-wide cache keyed by URL
161
+ */
162
+ export async function fetchJwks(
163
+ url: string,
164
+ fetchFn: typeof globalThis.fetch,
165
+ cache: Map<
166
+ string,
167
+ { keys: ReadonlyArray<Record<string, unknown>>; fetchedAt: number }
168
+ > = globalJwksCache,
169
+ ): Promise<ReadonlyArray<Record<string, unknown>>> {
170
+ const cached = cache.get(url);
171
+ if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
172
+ const res = await fetchFn(url, { headers: { accept: "application/json" } });
173
+ if (!res.ok) {
174
+ throw new OAuthProtocolError("jwks_fetch_failed", `JWKS fetch failed with HTTP ${res.status}`);
175
+ }
176
+ const body = (await res.json()) as { keys?: ReadonlyArray<Record<string, unknown>> };
177
+ if (!Array.isArray(body.keys)) {
178
+ throw new OAuthProtocolError("jwks_fetch_failed", "JWKS response missing keys array");
179
+ }
180
+ cache.set(url, { keys: body.keys, fetchedAt: Date.now() });
181
+ return body.keys;
182
+ }
183
+
184
+ const globalJwksCache = new Map<
185
+ string,
186
+ { keys: ReadonlyArray<Record<string, unknown>>; fetchedAt: number }
187
+ >();
188
+ const JWKS_TTL_MS = 10 * 60_000;
189
+
190
+ /**
191
+ * Drop a cached JWKS document (key-rotation retry).
192
+ *
193
+ * @param url - JWKS endpoint
194
+ */
195
+ export function clearJwksCache(url: string): void {
196
+ globalJwksCache.delete(url);
197
+ }
198
+
199
+ /**
200
+ * Pick the JWKS key matching the token's `kid` (falls back when only one key).
201
+ *
202
+ * @param keys - Provider keys
203
+ * @param kid - Token `kid`
204
+ */
205
+ export function selectJwksKey(
206
+ keys: ReadonlyArray<Record<string, unknown>>,
207
+ kid: unknown,
208
+ ): Record<string, unknown> {
209
+ if (typeof kid === "string") {
210
+ const match = keys.find((k) => k["kid"] === kid);
211
+ if (match) return match;
212
+ }
213
+ if (keys.length === 1) return keys[0]!;
214
+ throw new OAuthProtocolError("unknown_kid", "No JWKS key matches the ID token kid");
215
+ }
216
+
217
+ /**
218
+ * Build a form-encoded request init for token endpoints.
219
+ *
220
+ * @param params - Body parameters
221
+ * @param headers - Extra headers
222
+ */
223
+ export function formPost(
224
+ params: Readonly<Record<string, string>>,
225
+ headers: Readonly<Record<string, string>> = {},
226
+ ): RequestInit {
227
+ return {
228
+ method: "POST",
229
+ headers: {
230
+ "content-type": "application/x-www-form-urlencoded",
231
+ accept: "application/json",
232
+ ...headers,
233
+ },
234
+ body: new URLSearchParams(params).toString(),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Require a string field on a provider JSON response.
240
+ *
241
+ * @param source - Response object
242
+ * @param path - Dotted path (e.g. `"data.id"`)
243
+ * @param provider - Driver id for error context
244
+ */
245
+ export function requireString(
246
+ source: Readonly<Record<string, unknown>>,
247
+ path: string,
248
+ provider: OAuthDriverId,
249
+ ): string {
250
+ const value = pickPath(source, path);
251
+ if (typeof value !== "string" || value.length === 0) {
252
+ throw new OAuthProtocolError("profile_error", `${provider}: missing ${path}`);
253
+ }
254
+ return value;
255
+ }
256
+
257
+ /**
258
+ * Read a dotted path off a provider response.
259
+ *
260
+ * @param source - Response object
261
+ * @param path - Dotted path
262
+ */
263
+ export function pickPath(source: Readonly<Record<string, unknown>>, path: string): unknown {
264
+ let cur: unknown = source;
265
+ for (const part of path.split(".")) {
266
+ if (cur === null || typeof cur !== "object") return undefined;
267
+ cur = (cur as Record<string, unknown>)[part];
268
+ }
269
+ return cur;
270
+ }
271
+
272
+ /**
273
+ * Conservative email-verified parser — the GHSA-6g38-8j4p-j3pr defense.
274
+ *
275
+ * Truthy ONLY for boolean `true` or the exact strings `"true"` / `"1"`.
276
+ * Apple's `"false"` string and every absent / null / numeric value collapse
277
+ * to `false`. Never trust provider truthiness.
278
+ *
279
+ * @param value - Raw provider claim
280
+ */
281
+ export function parseEmailVerified(value: unknown): boolean {
282
+ if (value === true) return true;
283
+ if (typeof value === "string") return value === "true" || value === "1";
284
+ return false;
285
+ }
286
+
287
+ /**
288
+ * Normalize an optional email claim: absent/null → undefined, else trimmed
289
+ * lowercase string.
290
+ *
291
+ * @param value - Raw provider claim
292
+ */
293
+ export function normalizeEmail(value: unknown): string | undefined {
294
+ if (typeof value !== "string") return undefined;
295
+ const trimmed = value.trim().toLowerCase();
296
+ return trimmed.length > 0 ? trimmed : undefined;
297
+ }
298
+
299
+ /** Authorize-input fields shared by every driver's URL builder. */
300
+ export type CommonAuthorizeInput = OAuthAuthorizeInput;
301
+
302
+ /**
303
+ * Append authorize query parameters onto a base authorization URL.
304
+ *
305
+ * @param base - Authorization endpoint
306
+ * @param input - Authorize inputs
307
+ * @param extra - Provider-specific parameters
308
+ */
309
+ export function buildAuthorizeQuery(
310
+ base: string,
311
+ input: CommonAuthorizeInput,
312
+ extra: Readonly<Record<string, string>> = {},
313
+ ): string {
314
+ const url = new URL(base);
315
+ const params = url.searchParams;
316
+ params.set("response_type", "code");
317
+ params.set("client_id", input.clientId);
318
+ params.set("redirect_uri", input.redirectUri);
319
+ params.set("scope", input.scopes.join(" "));
320
+ params.set("state", input.state);
321
+ params.set("code_challenge", input.codeChallenge);
322
+ params.set("code_challenge_method", "S256");
323
+ if (input.nonce !== undefined) params.set("nonce", input.nonce);
324
+ for (const [key, value] of Object.entries(extra)) params.set(key, value);
325
+ return url.toString();
326
+ }