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,842 @@
1
+ /**
2
+ * Authorization Server HTTP surface — hand-rolled OAuth 2.1 endpoints on
3
+ * the app plane (6530), served through the plugin edge handler so no kernel
4
+ * routing changes are needed.
5
+ *
6
+ * Endpoints (fixed paths):
7
+ * - `GET /.well-known/oauth-authorization-server` (RFC 8414)
8
+ * - `GET /.well-known/oauth-protected-resource` (RFC 9728)
9
+ * - `GET /oauth/authorize` (302 to app consent UI or code redirect)
10
+ * - `POST /oauth/token` (authorization_code | refresh_token, DPoP required)
11
+ * - `GET /oauth/jwks` (public key set)
12
+ *
13
+ * Security posture (locked decisions):
14
+ * - CIMD client resolution only — no DCR
15
+ * - PKCE S256 mandatory; `resource` (RFC 8707) required on every request
16
+ * and validated against the configured canonical resource URI
17
+ * - DPoP required on every token grant; issued access tokens carry `cnf.jkt`
18
+ * - Exact `redirect_uri` membership against the fetched metadata
19
+ * - Codes are single-use and SHA-256 hashed at rest
20
+ */
21
+
22
+ import {
23
+ DPOP_ALG,
24
+ OAUTH_ACCESS_TTL_MS,
25
+ OAUTH_CODE_TTL_MS,
26
+ createDpopSigner,
27
+ decodeDpopProof,
28
+ jwkThumbprint,
29
+ signAccessToken,
30
+ verifyDpopProof,
31
+ type OAuthAccessClaims,
32
+ } from "./crypto.ts";
33
+ import { OAuthError } from "./errors.ts";
34
+ import { resolveCimdClient } from "./cimd.ts";
35
+ import { cryptoId, hashSecret } from "./stores.ts";
36
+ import type {
37
+ AccessTokenRow,
38
+ AsRefreshTokenRow,
39
+ AuthCodeRow,
40
+ ClientCacheRow,
41
+ ConsentRow,
42
+ PendingAuthorizeRow,
43
+ } from "./tables.ts";
44
+ import type { AsKeyStore } from "./crypto.ts";
45
+
46
+ /** Issuer identity + canonical MCP resource URI for one AS instance. */
47
+ export interface AsIdentityOptions {
48
+ /** Issuer origin, e.g. `https://api.example.com`. */
49
+ readonly issuer: string;
50
+ /** Canonical RFC 8707 resource URI clients must request, e.g. `https://mcp.example.com/mcp`. */
51
+ readonly resource: string;
52
+ }
53
+
54
+ /** Stores backing the AS (in-memory rows mirroring the declared tables). */
55
+ export interface AsStoreBundle {
56
+ readonly keys: AsKeyStore;
57
+ readonly authCodes: Map<string, AuthCodeRow>;
58
+ readonly accessTokens: Map<string, AccessTokenRow>;
59
+ readonly refreshTokens: Map<string, AsRefreshTokenRow>;
60
+ readonly consents: Map<string, ConsentRow>;
61
+ readonly clientCache: Map<string, ClientCacheRow>;
62
+ /** Validated authorize requests awaiting the user's consent decision. */
63
+ readonly pending: Map<string, PendingAuthorizeRow>;
64
+ }
65
+
66
+ /** Options for {@link createOauthAs}. */
67
+ export interface OauthAsOptions extends AsIdentityOptions {
68
+ readonly stores: AsStoreBundle;
69
+ readonly now?: () => number;
70
+ /** Injectable CIMD document loader (tests). */
71
+ readonly fetchDoc?: (url: string) => Promise<unknown>;
72
+ /**
73
+ * App consent screen path — authorize redirects unconsented users here
74
+ * with `?consent=<id>` (the developer's own SPA renders the screen).
75
+ */
76
+ readonly consentPath?: string;
77
+ /** Refresh-token TTL (default 14 days). */
78
+ readonly refreshTtlMs?: number;
79
+ /**
80
+ * Resolve the signed-in gate.auth user id from an incoming browser
81
+ * request (cookie/Bearer session) — used by `/oauth/authorize`.
82
+ */
83
+ readonly userFromRequest?: (request: Request) => Promise<string | undefined>;
84
+ }
85
+
86
+ /** Result of an authorize evaluation before any redirect decision. */
87
+ export interface AuthorizeEvaluation {
88
+ readonly ok: boolean;
89
+ /** When false, redirect the browser to this error redirect_uri. */
90
+ readonly errorRedirect?: string;
91
+ /** When consent is missing, redirect here (app-built consent screen). */
92
+ readonly consentRedirect?: string;
93
+ /** When ok, redirect here with the fresh code (+state). */
94
+ readonly codeRedirect?: string;
95
+ /** Pending-consent id surfaced to the consent screen / approve Flow. */
96
+ readonly pendingConsentId?: string;
97
+ }
98
+
99
+ const AUTHORIZE_PATH = "/oauth/authorize";
100
+ const TOKEN_PATH = "/oauth/token";
101
+ const JWKS_PATH = "/oauth/jwks";
102
+ const AS_METADATA_PATH = "/.well-known/oauth-authorization-server";
103
+ const PRM_METADATA_PATH = "/.well-known/oauth-protected-resource";
104
+
105
+ /**
106
+ * Build the RFC 8414 authorization-server metadata document.
107
+ *
108
+ * @param issuer - Issuer origin
109
+ * @param resource - Canonical resource URI advertised in `scopes` docs
110
+ */
111
+ export function buildAuthorizationServerMetadata(issuer: string): Record<string, unknown> {
112
+ const base = `${issuer.replace(/\/$/, "")}`;
113
+ return {
114
+ issuer: base,
115
+ authorization_endpoint: `${base}${AUTHORIZE_PATH}`,
116
+ token_endpoint: `${base}${TOKEN_PATH}`,
117
+ jwks_uri: `${base}${JWKS_PATH}`,
118
+ response_types_supported: ["code"],
119
+ grant_types_supported: ["authorization_code", "refresh_token"],
120
+ code_challenge_methods_supported: ["S256"],
121
+ token_endpoint_auth_methods_supported: ["none"],
122
+ dpop_signing_alg_values_supported: [DPOP_ALG],
123
+ scopes_supported: ["openid", "profile", "email", "mcp:tools"],
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Build the RFC 9728 protected-resource metadata document.
129
+ *
130
+ * @param resource - Canonical resource URI
131
+ * @param issuer - AS issuer advertising authorization + token endpoints
132
+ */
133
+ export function buildProtectedResourceMetadata(
134
+ resource: string,
135
+ issuer: string,
136
+ ): Record<string, unknown> {
137
+ const base = `${issuer.replace(/\/$/, "")}`;
138
+ return {
139
+ resource,
140
+ authorization_servers: [base],
141
+ bearer_methods_supported: ["header"],
142
+ resource_documentation: `${base}`,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * RFC 6750 / RFC 9728 §5.1 challenge pointing unauthenticated MCP clients
148
+ * at the protected-resource metadata.
149
+ *
150
+ * @param resource - Canonical resource URI
151
+ * @param issuer - AS issuer
152
+ */
153
+ export function wwwAuthenticateChallenge(resource: string, _issuer: string): string {
154
+ const prm = `${new URL(resource).origin}${PRM_METADATA_PATH}`;
155
+ return `Bearer resource_metadata="${prm}"`;
156
+ }
157
+
158
+ /**
159
+ * Create the edge handler serving all AS endpoints.
160
+ *
161
+ * @param options - Identity, stores, clock, fetch injection
162
+ */
163
+ export function createOauthAs(options: OauthAsOptions): {
164
+ /** Plugin edge handler for the fixed AS paths. */
165
+ edge(
166
+ request: Request,
167
+ info: { readonly method: string; readonly path: string },
168
+ ): Promise<Response | undefined>;
169
+ /** Display data for the app consent screen. */
170
+ describeConsent(pendingId: string): ConsentDescription;
171
+ /** Grant consent + mint the authorization code (session user must match). */
172
+ approveConsent(pendingId: string, userId: string): Promise<{ readonly redirectTo: string }>;
173
+ /** Deny consent — redirects with `error=access_denied`. */
174
+ denyConsent(pendingId: string, userId: string): { readonly redirectTo: string };
175
+ } {
176
+ const now = options.now ?? (() => Date.now());
177
+ const refreshTtlMs = options.refreshTtlMs ?? 14 * 24 * 60 * 60_000;
178
+ const stores = options.stores;
179
+
180
+ const findPending = (pendingId: string): PendingAuthorizeRow => {
181
+ const row = stores.pending.get(pendingId);
182
+ if (!row || row.expiresAt <= now()) {
183
+ throw new OAuthError("invalid_request", "unknown or expired consent request");
184
+ }
185
+ return row;
186
+ };
187
+
188
+ return {
189
+ async edge(request, info) {
190
+ switch (info.path) {
191
+ case AS_METADATA_PATH:
192
+ if (info.method === "GET") {
193
+ return Response.json(buildAuthorizationServerMetadata(options.issuer));
194
+ }
195
+ return undefined;
196
+ case PRM_METADATA_PATH:
197
+ if (info.method === "GET") {
198
+ return Response.json(buildProtectedResourceMetadata(options.resource, options.issuer));
199
+ }
200
+ return undefined;
201
+ case JWKS_PATH:
202
+ if (info.method === "GET") {
203
+ return Response.json(stores.keys.jwks());
204
+ }
205
+ return undefined;
206
+ case TOKEN_PATH:
207
+ if (info.method === "POST") return handleToken(request, options, now, refreshTtlMs);
208
+ return undefined;
209
+ case AUTHORIZE_PATH:
210
+ if (info.method === "GET") return handleAuthorize(request, options, now);
211
+ return undefined;
212
+ default:
213
+ return undefined;
214
+ }
215
+ },
216
+
217
+ describeConsent(pendingId) {
218
+ const row = findPending(pendingId);
219
+ return {
220
+ clientId: row.clientId,
221
+ clientName: row.clientName,
222
+ scope: [...row.scope],
223
+ resource: row.resource,
224
+ };
225
+ },
226
+
227
+ async approveConsent(pendingId, userId) {
228
+ const row = findPending(pendingId);
229
+ if (row.userId !== userId) throw new OAuthError("access_denied", "consent user mismatch");
230
+ upsertConsent(stores, {
231
+ userId: row.userId,
232
+ clientId: row.clientId,
233
+ clientName: row.clientName,
234
+ resource: row.resource,
235
+ scope: row.scope,
236
+ now: now(),
237
+ });
238
+ const raw = await mintAuthorizationCode(stores, {
239
+ userId: row.userId,
240
+ clientId: row.clientId,
241
+ redirectUri: row.redirectUri,
242
+ resource: row.resource,
243
+ scope: row.scope,
244
+ codeChallenge: row.codeChallenge,
245
+ jkt: null,
246
+ now: now(),
247
+ });
248
+ stores.pending.delete(pendingId);
249
+ return {
250
+ redirectTo: appendQuery(row.redirectUri, {
251
+ code: raw,
252
+ iss: options.issuer.replace(/\/$/, ""),
253
+ ...(row.state !== undefined ? { state: row.state } : {}),
254
+ }),
255
+ };
256
+ },
257
+
258
+ denyConsent(pendingId, userId) {
259
+ const row = findPending(pendingId);
260
+ if (row.userId !== userId) throw new OAuthError("access_denied", "consent user mismatch");
261
+ stores.pending.delete(pendingId);
262
+ return {
263
+ redirectTo: appendQuery(row.redirectUri, {
264
+ error: "access_denied",
265
+ error_description: "resource owner denied the request",
266
+ ...(row.state !== undefined ? { state: row.state } : {}),
267
+ }),
268
+ };
269
+ },
270
+ };
271
+ }
272
+
273
+ /** Consent-screen payload. */
274
+ export interface ConsentDescription {
275
+ readonly clientId: string;
276
+ readonly clientName: string | null;
277
+ readonly scope: readonly string[];
278
+ readonly resource: string;
279
+ }
280
+
281
+ /** Backwards-compatible alias for the edge handler alone. */
282
+ export const createOauthAsEdge = (
283
+ options: OauthAsOptions,
284
+ ): ((
285
+ request: Request,
286
+ info: { readonly method: string; readonly path: string },
287
+ ) => Promise<Response | undefined>) => createOauthAs(options).edge;
288
+
289
+ /* ------------------------------------------------------------------ */
290
+ /* Authorize */
291
+ /* ------------------------------------------------------------------ */
292
+
293
+ async function handleAuthorize(
294
+ request: Request,
295
+ options: OauthAsOptions,
296
+ now: () => number,
297
+ ): Promise<Response> {
298
+ const url = new URL(request.url);
299
+ const failRedirect = (redirectUri: string, code: string, description: string, state?: string) =>
300
+ Response.redirect(
301
+ appendQuery(redirectUri, {
302
+ error: code,
303
+ error_description: description,
304
+ ...(state !== undefined ? { state } : {}),
305
+ }),
306
+ 302,
307
+ );
308
+
309
+ const responseType = url.searchParams.get("response_type");
310
+ const clientId = url.searchParams.get("client_id");
311
+ const redirectUri = url.searchParams.get("redirect_uri");
312
+ const scopeRaw = url.searchParams.get("scope");
313
+ const state = url.searchParams.get("state") ?? undefined;
314
+ const resource = url.searchParams.get("resource");
315
+ const challenge = url.searchParams.get("code_challenge");
316
+ const challengeMethod = url.searchParams.get("code_challenge_method");
317
+
318
+ let client: ClientCacheRow;
319
+ try {
320
+ if (clientId === null) {
321
+ throw new OAuthError("invalid_request", "missing client_id");
322
+ }
323
+ client = await resolveCimdClient(options.stores.clientCache, clientId, now(), options.fetchDoc);
324
+ } catch (err) {
325
+ return Response.json(oauthErrorBody(err), { status: statusOf(err) });
326
+ }
327
+ if (redirectUri === null || !client.metadata.redirect_uris.includes(redirectUri)) {
328
+ return Response.json(
329
+ oauthErrorBody(new OAuthError("invalid_request", "redirect_uri not registered")),
330
+ { status: 400 },
331
+ );
332
+ }
333
+
334
+ // Narrow to non-null for the rest of the handler.
335
+ const resolvedClientId: string = clientId;
336
+
337
+ if (responseType !== "code") {
338
+ return failRedirect(
339
+ redirectUri,
340
+ "unsupported_response_type",
341
+ "response_type must be code",
342
+ state,
343
+ );
344
+ }
345
+ if (challenge === null || challengeMethod !== "S256") {
346
+ return failRedirect(redirectUri, "invalid_request", "PKCE S256 is required", state);
347
+ }
348
+ // RFC 8707 MUST — every authorization request carries resource, matching
349
+ // the canonical URI exactly (no prefix games).
350
+ if (resource !== options.resource) {
351
+ return failRedirect(
352
+ redirectUri,
353
+ "invalid_target",
354
+ `resource must be ${options.resource}`,
355
+ state,
356
+ );
357
+ }
358
+
359
+ const requestedScopes = scopeRaw !== null ? scopeRaw.split(" ").filter(Boolean) : [];
360
+
361
+ // The human must already have a gate.auth user-plane session.
362
+ const userId = await resolveUserId(request, options);
363
+ if (userId === undefined) {
364
+ const loginUrl = new URL(AUTHORIZE_PATH, url.origin);
365
+ loginUrl.search = url.search;
366
+ return Response.redirect(
367
+ `${url.origin}/auth/sign-in?next=${encodeURIComponent(loginUrl.pathname + loginUrl.search)}`,
368
+ 302,
369
+ );
370
+ }
371
+
372
+ const existing = findConsent(options.stores.consents, userId, resolvedClientId, options.resource);
373
+ const covered =
374
+ existing !== undefined &&
375
+ existing.revokedAt === null &&
376
+ requestedScopes.every((s) => existing.scope.includes(s));
377
+
378
+ if (!covered) {
379
+ // Hand the decision to the app's own consent screen: park the fully
380
+ // validated context server-side; the screen only ever sees a pending id.
381
+ const pendingId = cryptoId();
382
+ options.stores.pending.set(pendingId, {
383
+ id: pendingId,
384
+ userId,
385
+ clientId: resolvedClientId,
386
+ clientName: client.metadata.client_name ?? null,
387
+ redirectUri,
388
+ resource: options.resource,
389
+ scope: requestedScopes,
390
+ codeChallenge: challenge,
391
+ state,
392
+ expiresAt: now() + OAUTH_CODE_TTL_MS * 10,
393
+ });
394
+ const target = options.consentPath ?? "/oauth/consent";
395
+ return Response.redirect(
396
+ appendQuery(target.startsWith("/") ? `${url.origin}${target}` : target, {
397
+ consent: pendingId,
398
+ }),
399
+ 302,
400
+ );
401
+ }
402
+
403
+ const code = await mintAuthorizationCode(options.stores, {
404
+ userId,
405
+ clientId: resolvedClientId,
406
+ redirectUri,
407
+ resource: options.resource,
408
+ scope: requestedScopes.length > 0 ? requestedScopes : (existing?.scope ?? []),
409
+ codeChallenge: challenge,
410
+ jkt: null,
411
+ now: now(),
412
+ });
413
+ return Response.redirect(
414
+ appendQuery(redirectUri, {
415
+ code,
416
+ iss: options.issuer.replace(/\/$/, ""),
417
+ ...(state !== undefined ? { state } : {}),
418
+ }),
419
+ 302,
420
+ );
421
+ }
422
+
423
+ /* ------------------------------------------------------------------ */
424
+ /* Token */
425
+ /* ------------------------------------------------------------------ */
426
+
427
+ type FormParams = URLSearchParams;
428
+
429
+ async function handleToken(
430
+ request: Request,
431
+ options: OauthAsOptions,
432
+ now: () => number,
433
+ refreshTtlMs: number,
434
+ ): Promise<Response> {
435
+ let form: FormParams;
436
+ try {
437
+ const raw = await request.text();
438
+ form = new URLSearchParams(raw);
439
+ } catch {
440
+ return jsonError(new OAuthError("invalid_request", "form-encoded body required"));
441
+ }
442
+ const grantType = form.get("grant_type");
443
+ try {
444
+ if (grantType === "authorization_code") {
445
+ return jsonOk(await tokenByCode(request, form, options, now, refreshTtlMs));
446
+ }
447
+ if (grantType === "refresh_token") {
448
+ return jsonOk(await tokenByRefresh(request, form, options, now, refreshTtlMs));
449
+ }
450
+ throw new OAuthError("unsupported_grant_type", "only authorization_code and refresh_token");
451
+ } catch (err) {
452
+ return jsonError(err);
453
+ }
454
+ }
455
+
456
+ function tokenEndpointUrl(options: OauthAsOptions, _incoming: Request): string {
457
+ return `${options.issuer.replace(/\/$/, "")}/oauth/token`;
458
+ }
459
+
460
+ async function tokenByCode(
461
+ request: Request,
462
+ form: FormParams,
463
+ options: OauthAsOptions,
464
+ now: () => number,
465
+ refreshTtlMs: number,
466
+ ): Promise<TokenResponse> {
467
+ const t = now();
468
+ const code = form.get("code");
469
+ const clientId = form.get("client_id");
470
+ const redirectUri = form.get("redirect_uri");
471
+ const verifier = form.get("code_verifier");
472
+ const resource = form.get("resource");
473
+ if (!code || !clientId || !redirectUri || !verifier || !resource) {
474
+ throw new OAuthError(
475
+ "invalid_request",
476
+ "code, client_id, redirect_uri, code_verifier, resource are required",
477
+ );
478
+ }
479
+ if (resource !== options.resource) {
480
+ throw new OAuthError("invalid_target", `resource must be ${options.resource}`);
481
+ }
482
+
483
+ const codeHash = await hashSecret(code);
484
+ const row = [...options.stores.authCodes.values()].find((r) => r.codeHash === codeHash);
485
+ if (!row) throw new OAuthError("invalid_grant", "unknown authorization code");
486
+ if (row.consumedAt !== null) throw new OAuthError("invalid_grant", "authorization code replayed");
487
+ if (row.expiresAt <= t) throw new OAuthError("invalid_grant", "authorization code expired");
488
+ if (row.clientId !== clientId) throw new OAuthError("invalid_grant", "client mismatch");
489
+ if (row.redirectUri !== redirectUri)
490
+ throw new OAuthError("invalid_grant", "redirect_uri mismatch");
491
+ if (row.resource !== resource) throw new OAuthError("invalid_target", "resource mismatch");
492
+
493
+ const expectedChallenge = await pkceS256(verifier);
494
+ if (expectedChallenge !== row.codeChallenge) {
495
+ throw new OAuthError("invalid_grant", "PKCE verification failed");
496
+ }
497
+
498
+ // DPoP is mandatory (locked decision 2) — the proof binds the token to
499
+ // this client's key via cnf.jkt from here forward.
500
+ const proof = request.headers.get("dpop");
501
+ if (!proof) throw new OAuthError("invalid_request", "DPoP proof required");
502
+ const htu = tokenEndpointUrl(options, request);
503
+ const jwk = await verifyDpopProof(proof, { htm: "POST", htu, now: t, accessToken: undefined });
504
+ const jkt = await jwkThumbprint(jwk);
505
+ if (row.jkt !== null && row.jkt !== jkt) {
506
+ throw new OAuthError("invalid_grant", "DPoP key mismatch for this authorization");
507
+ }
508
+
509
+ row.consumedAt = t;
510
+
511
+ return issueTokenPair(options, {
512
+ userId: row.userId,
513
+ clientId: row.clientId,
514
+ resource: row.resource,
515
+ scope: row.scope,
516
+ jkt,
517
+ now: t,
518
+ refreshTtlMs,
519
+ });
520
+ }
521
+
522
+ async function tokenByRefresh(
523
+ request: Request,
524
+ form: FormParams,
525
+ options: OauthAsOptions,
526
+ now: () => number,
527
+ refreshTtlMs: number,
528
+ ): Promise<TokenResponse> {
529
+ const t = now();
530
+ const raw = form.get("refresh_token");
531
+ const clientId = form.get("client_id");
532
+ const resource = form.get("resource");
533
+ if (!raw || !clientId || !resource) {
534
+ throw new OAuthError("invalid_request", "refresh_token, client_id, resource are required");
535
+ }
536
+ if (resource !== options.resource) {
537
+ throw new OAuthError("invalid_target", `resource must be ${options.resource}`);
538
+ }
539
+ const hash = await hashSecret(raw);
540
+ const row = [...options.stores.refreshTokens.values()].find((r) => r.hash === hash);
541
+ if (!row) throw new OAuthError("invalid_grant", "unknown refresh token");
542
+
543
+ // Rotation with family reuse detection (same model as oke_refresh_tokens).
544
+ if (row.usedAt !== null || row.revokedAt !== null) {
545
+ revokeRefreshFamily(options.stores.refreshTokens, row.familyId, t);
546
+ revokeUserClientAccessTokens(options.stores.accessTokens, row.userId, row.clientId, t);
547
+ throw new OAuthError("invalid_grant", "refresh token reuse detected; family revoked");
548
+ }
549
+ if (row.expiresAt <= t) throw new OAuthError("invalid_grant", "refresh token expired");
550
+ if (row.clientId !== clientId) throw new OAuthError("invalid_grant", "client mismatch");
551
+ if (row.resource !== resource) throw new OAuthError("invalid_target", "resource mismatch");
552
+
553
+ const proof = request.headers.get("dpop");
554
+ if (!proof) throw new OAuthError("invalid_request", "DPoP proof required");
555
+ const htu = tokenEndpointUrl(options, request);
556
+ const jwk = await verifyDpopProof(proof, { htm: "POST", htu, now: t });
557
+ const jkt = await jwkThumbprint(jwk);
558
+ if (row.jkt !== jkt) {
559
+ throw new OAuthError("invalid_grant", "DPoP key mismatch for this grant");
560
+ }
561
+
562
+ // Rotate: burn this row, issue a fresh pair bound to the same key and
563
+ // the SAME family so reuse detection can revoke everything downstream.
564
+ row.usedAt = t;
565
+ row.revokedAt = t;
566
+
567
+ return issueTokenPair(options, {
568
+ userId: row.userId,
569
+ clientId: row.clientId,
570
+ resource: row.resource,
571
+ scope: row.scope,
572
+ jkt,
573
+ now: t,
574
+ refreshTtlMs,
575
+ familyId: row.familyId,
576
+ });
577
+ }
578
+
579
+ /* ------------------------------------------------------------------ */
580
+ /* Shared issuance */
581
+ /* ------------------------------------------------------------------ */
582
+
583
+ /** Token endpoint success payload (RFC 6749 §5.1 + DPoP fields). */
584
+ export interface TokenResponse {
585
+ readonly access_token: string;
586
+ readonly token_type: "DPoP";
587
+ readonly expires_in: number;
588
+ readonly refresh_token?: string;
589
+ readonly scope: string;
590
+ }
591
+
592
+ async function issueTokenPair(
593
+ options: OauthAsOptions,
594
+ input: {
595
+ userId: string;
596
+ clientId: string;
597
+ resource: string;
598
+ scope: readonly string[];
599
+ jkt: string;
600
+ now: number;
601
+ refreshTtlMs: number;
602
+ /** Continue an existing refresh family (rotation keeps the same id). */
603
+ familyId?: string;
604
+ },
605
+ ): Promise<TokenResponse> {
606
+ const jti = cryptoId();
607
+ const expiresInSec = Math.floor(OAUTH_ACCESS_TTL_MS / 1000);
608
+ const claims: OAuthAccessClaims = {
609
+ iss: options.issuer.replace(/\/$/, ""),
610
+ sub: input.userId,
611
+ client_id: input.clientId,
612
+ aud: input.resource,
613
+ scope: input.scope.join(" "),
614
+ iat: Math.floor(input.now / 1000),
615
+ exp: Math.floor(input.now / 1000) + expiresInSec,
616
+ jti,
617
+ cnf: { jkt: input.jkt },
618
+ };
619
+ const accessToken = await signAccessToken(options.stores.keys, claims);
620
+
621
+ options.stores.accessTokens.set(jti, {
622
+ id: jti,
623
+ userId: input.userId,
624
+ clientId: input.clientId,
625
+ resource: input.resource,
626
+ scope: input.scope,
627
+ jkt: input.jkt,
628
+ expiresAt: input.now + OAUTH_ACCESS_TTL_MS,
629
+ revokedAt: null,
630
+ createdAt: input.now,
631
+ });
632
+
633
+ const refreshRaw = `ort_${cryptoId()}`;
634
+ const familyId = input.familyId ?? cryptoId();
635
+ // Keyed by row id — a family keeps multiple generations side by side so
636
+ // reuse detection can find (and revoke) every one of them.
637
+ const rowId = cryptoId();
638
+ options.stores.refreshTokens.set(rowId, {
639
+ id: rowId,
640
+ familyId,
641
+ userId: input.userId,
642
+ clientId: input.clientId,
643
+ resource: input.resource,
644
+ scope: input.scope,
645
+ jkt: input.jkt,
646
+ hash: await hashSecret(refreshRaw),
647
+ expiresAt: input.now + input.refreshTtlMs,
648
+ usedAt: null,
649
+ revokedAt: null,
650
+ });
651
+
652
+ return {
653
+ access_token: accessToken,
654
+ token_type: "DPoP",
655
+ expires_in: expiresInSec,
656
+ refresh_token: refreshRaw,
657
+ scope: input.scope.join(" "),
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Mint a single-use authorization code (authorize redirect path and the
663
+ * consent-approve Flow both land here).
664
+ *
665
+ * @param stores - Store bundle
666
+ * @param input - Grant context captured at authorize time
667
+ */
668
+ export async function mintAuthorizationCode(
669
+ stores: AsStoreBundle,
670
+ input: {
671
+ userId: string;
672
+ clientId: string;
673
+ redirectUri: string;
674
+ resource: string;
675
+ scope: readonly string[];
676
+ codeChallenge: string;
677
+ jkt: string | null;
678
+ now: number;
679
+ },
680
+ ): Promise<string> {
681
+ const raw = `oac_${cryptoId()}`;
682
+ const id = cryptoId();
683
+ stores.authCodes.set(id, {
684
+ id,
685
+ codeHash: await hashSecret(raw),
686
+ userId: input.userId,
687
+ clientId: input.clientId,
688
+ redirectUri: input.redirectUri,
689
+ resource: input.resource,
690
+ scope: input.scope,
691
+ codeChallenge: input.codeChallenge,
692
+ codeChallengeMethod: "S256",
693
+ jkt: input.jkt,
694
+ expiresAt: input.now + OAUTH_CODE_TTL_MS,
695
+ consumedAt: null,
696
+ createdAt: input.now,
697
+ });
698
+ pruneExpired(stores.authCodes, input.now);
699
+ return raw;
700
+ }
701
+
702
+ /**
703
+ * Approve consent for `(user, client)` covering the requested scopes
704
+ * (called by the app's consent-approve Flow after the user clicks allow).
705
+ *
706
+ * @param stores - Store bundle
707
+ * @param input - Consent context
708
+ */
709
+ export function upsertConsent(
710
+ stores: AsStoreBundle,
711
+ input: {
712
+ userId: string;
713
+ clientId: string;
714
+ clientName: string | null;
715
+ resource: string;
716
+ scope: readonly string[];
717
+ now: number;
718
+ },
719
+ ): void {
720
+ const key = consentKey(input.userId, input.clientId);
721
+ const existing = stores.consents.get(key);
722
+ if (existing && existing.revokedAt === null) {
723
+ const merged = [...new Set([...existing.scope, ...input.scope])];
724
+ stores.consents.set(key, { ...existing, scope: merged, updatedAt: input.now });
725
+ return;
726
+ }
727
+ stores.consents.set(key, {
728
+ userId: input.userId,
729
+ clientId: input.clientId,
730
+ clientName: input.clientName,
731
+ resource: input.resource,
732
+ scope: [...input.scope],
733
+ grantedAt: existing?.grantedAt ?? input.now,
734
+ updatedAt: input.now,
735
+ revokedAt: null,
736
+ });
737
+ }
738
+
739
+ function findConsent(
740
+ consents: Map<string, ConsentRow>,
741
+ userId: string,
742
+ clientId: string,
743
+ _resource: string,
744
+ ): ConsentRow | undefined {
745
+ return consents.get(consentKey(userId, clientId));
746
+ }
747
+
748
+ function consentKey(userId: string, clientId: string): string {
749
+ return `${userId}:${clientId}`;
750
+ }
751
+
752
+ function revokeRefreshFamily(
753
+ refreshes: Map<string, AsRefreshTokenRow>,
754
+ familyId: string,
755
+ at: number,
756
+ ): void {
757
+ for (const row of refreshes.values()) {
758
+ if (row.familyId === familyId && row.revokedAt === null) row.revokedAt = at;
759
+ }
760
+ }
761
+
762
+ function revokeUserClientAccessTokens(
763
+ tokens: Map<string, AccessTokenRow>,
764
+ userId: string,
765
+ clientId: string,
766
+ at: number,
767
+ ): void {
768
+ for (const row of tokens.values()) {
769
+ if (row.userId === userId && row.clientId === clientId && row.revokedAt === null) {
770
+ row.revokedAt = at;
771
+ }
772
+ }
773
+ }
774
+
775
+ function pruneExpired(codes: Map<string, AuthCodeRow>, now: number): void {
776
+ for (const [id, row] of codes) {
777
+ if (row.expiresAt <= now || row.consumedAt !== null) codes.delete(id);
778
+ }
779
+ }
780
+
781
+ async function resolveUserId(
782
+ request: Request,
783
+ options: OauthAsOptions,
784
+ ): Promise<string | undefined> {
785
+ const resolver = options.userFromRequest;
786
+ if (!resolver) return undefined;
787
+ try {
788
+ return await resolver(request);
789
+ } catch {
790
+ return undefined;
791
+ }
792
+ }
793
+
794
+ /* ------------------------------------------------------------------ */
795
+ /* Rendering helpers */
796
+ /* ------------------------------------------------------------------ */
797
+
798
+ function oauthErrorBody(err: unknown): { error: string; error_description?: string } {
799
+ if (err instanceof OAuthError) {
800
+ return {
801
+ error: err.code,
802
+ ...(err.description ? { error_description: err.description } : {}),
803
+ };
804
+ }
805
+ return { error: "server_error" };
806
+ }
807
+
808
+ function statusOf(err: unknown): number {
809
+ if (err instanceof OAuthError) return err.status;
810
+ return 500;
811
+ }
812
+
813
+ function jsonOk(body: TokenResponse): Response {
814
+ return Response.json(body, {
815
+ headers: { "cache-control": "no-store", pragma: "no-cache" },
816
+ });
817
+ }
818
+
819
+ function jsonError(err: unknown): Response {
820
+ return Response.json(oauthErrorBody(err), {
821
+ status: statusOf(err),
822
+ headers: { "cache-control": "no-store", pragma: "no-cache" },
823
+ });
824
+ }
825
+
826
+ function appendQuery(uri: string, params: Record<string, string>): string {
827
+ const url = new URL(uri);
828
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
829
+ return url.toString();
830
+ }
831
+
832
+ async function pkceS256(verifier: string): Promise<string> {
833
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
834
+ const bytes = new Uint8Array(digest);
835
+ let bin = "";
836
+ for (const b of bytes) bin += String.fromCharCode(b);
837
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
838
+ }
839
+
840
+ // Re-exported for tests / consent flows.
841
+ export { createDpopSigner, decodeDpopProof, OAuthError };
842
+ export { createAsStores, cryptoId, hashSecret } from "./stores.ts";