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,419 @@
1
+ /**
2
+ * End-to-end AS endpoint tests — CIMD resolution, authorize validation,
3
+ * PKCE, DPoP, resource binding, refresh rotation. Uses an injected CIMD
4
+ * loader and a fixed clock; no network.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { createDpopSigner } from "./crypto.ts";
9
+ import { OAuthError } from "./errors.ts";
10
+ import { hashSecret } from "./stores.ts";
11
+ import {
12
+ buildAuthorizationServerMetadata,
13
+ buildProtectedResourceMetadata,
14
+ createOauthAs,
15
+ upsertConsent,
16
+ type OauthAsOptions,
17
+ } from "./http.ts";
18
+ import { createAsStores } from "./stores.ts";
19
+
20
+ const T0 = 1_700_000_000_000;
21
+ const now = () => T0;
22
+
23
+ const CLIENT_ID = "https://app.example.com/oauth-client-metadata.json";
24
+ const RESOURCE = "https://mcp.example.com/mcp";
25
+ const REDIRECT = "https://app.example.com/callback";
26
+
27
+ function makeCimdDoc(): Record<string, unknown> {
28
+ return {
29
+ client_id: CLIENT_ID,
30
+ client_name: "Test Agent",
31
+ redirect_uris: [REDIRECT],
32
+ scope: "mcp:tools openid",
33
+ };
34
+ }
35
+
36
+ function makeOptions(): OauthAsOptions & { stores: ReturnType<typeof createAsStores> } {
37
+ return {
38
+ issuer: "https://as.example.com",
39
+ resource: RESOURCE,
40
+ stores: createAsStores(),
41
+ now,
42
+ fetchDoc: async (url) => (url === CLIENT_ID ? makeCimdDoc() : {}),
43
+ userFromRequest: async () => "user-1",
44
+ };
45
+ }
46
+
47
+ /** Pre-seed a covering consent row so authorize issues a code directly. */
48
+ function seedConsent(options: ReturnType<typeof makeOptions>, scope: string): void {
49
+ upsertConsent(options.stores, {
50
+ userId: "user-1",
51
+ clientId: CLIENT_ID,
52
+ clientName: "Test Agent",
53
+ resource: RESOURCE,
54
+ scope: scope.split(" "),
55
+ now: T0,
56
+ });
57
+ }
58
+
59
+ /** Drive GET /oauth/authorize and pull the code out of the Location. */
60
+ async function authorize(
61
+ options: typeof makeOptions extends () => infer O ? O : never,
62
+ query: Record<string, string>,
63
+ ): Promise<{ status: number; location?: string; body: unknown }> {
64
+ const url = new URL("https://as.example.com/oauth/authorize");
65
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
66
+ const facade = createOauthAs(options);
67
+ const response = await facade.edge(new Request(url.toString()), {
68
+ method: "GET",
69
+ path: "/oauth/authorize",
70
+ });
71
+ if (!response) throw new Error("authorize not handled");
72
+ const location = response.headers.get("location") ?? undefined;
73
+ return {
74
+ status: response.status,
75
+ location,
76
+ body: response.status === 302 ? undefined : await response.json(),
77
+ };
78
+ }
79
+
80
+ async function pkcePair(): Promise<{ verifier: string; challenge: string }> {
81
+ const verifier = `v-${crypto.randomUUID()}`;
82
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
83
+ const bytes = new Uint8Array(digest);
84
+ let bin = "";
85
+ for (const b of bytes) bin += String.fromCharCode(b);
86
+ const challenge = btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
87
+ return { verifier, challenge };
88
+ }
89
+
90
+ async function postToken(
91
+ options: ReturnType<typeof makeOptions>,
92
+ form: Record<string, string>,
93
+ proof?: string,
94
+ ): Promise<Response> {
95
+ const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded" };
96
+ if (proof !== undefined) headers.dpop = proof;
97
+ const request = new Request("https://as.example.com/oauth/token", {
98
+ method: "POST",
99
+ headers,
100
+ body: new URLSearchParams(form),
101
+ });
102
+ const facade = createOauthAs(options);
103
+ const response = await facade.edge(request, { method: "POST", path: "/oauth/token" });
104
+ if (!response) throw new Error("token endpoint not handled");
105
+ return response;
106
+ }
107
+
108
+ describe("oauth-as endpoints", () => {
109
+ test("metadata documents advertise DPoP + S256 + resource", async () => {
110
+ expect(buildAuthorizationServerMetadata("https://as.example.com/")).toMatchObject({
111
+ issuer: "https://as.example.com",
112
+ token_endpoint: "https://as.example.com/oauth/token",
113
+ dpop_signing_alg_values_supported: ["ES256"],
114
+ code_challenge_methods_supported: ["S256"],
115
+ });
116
+ expect(buildProtectedResourceMetadata(RESOURCE, "https://as.example.com")).toMatchObject({
117
+ resource: RESOURCE,
118
+ authorization_servers: ["https://as.example.com"],
119
+ });
120
+ });
121
+
122
+ test("full happy path: CIMD authorize → code → DPoP token", async () => {
123
+ const options = makeOptions();
124
+ seedConsent(options, "mcp:tools");
125
+ const { verifier, challenge } = await pkcePair();
126
+ const authRes = await authorize(options, {
127
+ response_type: "code",
128
+ client_id: CLIENT_ID,
129
+ redirect_uri: REDIRECT,
130
+ scope: "mcp:tools",
131
+ state: "xyz",
132
+ resource: RESOURCE,
133
+ code_challenge: challenge,
134
+ code_challenge_method: "S256",
135
+ });
136
+ expect(authRes.status).toBe(302);
137
+ const location = authRes.location ?? "";
138
+ expect(location.startsWith(`${REDIRECT}?`)).toBe(true);
139
+ const params = new URL(location);
140
+ const code = params.searchParams.get("code");
141
+ expect(code).toBeTruthy();
142
+ expect(params.searchParams.get("state")).toBe("xyz");
143
+ expect(params.searchParams.get("iss")).toBe("https://as.example.com");
144
+
145
+ // Code must be single-use and stored hashed.
146
+ const codeHash = await hashSecret(code ?? "");
147
+ const rows = [...options.stores.authCodes.values()];
148
+ expect(rows.some((r) => r.codeHash === codeHash)).toBe(true);
149
+
150
+ const signer = await createDpopSigner();
151
+ const proof = await signer.prove({
152
+ htm: "POST",
153
+ htu: "https://as.example.com/oauth/token",
154
+ now: T0,
155
+ });
156
+ const tokenRes = await postToken(
157
+ options,
158
+ {
159
+ grant_type: "authorization_code",
160
+ code: code ?? "",
161
+ client_id: CLIENT_ID,
162
+ redirect_uri: REDIRECT,
163
+ code_verifier: verifier,
164
+ resource: RESOURCE,
165
+ },
166
+ proof,
167
+ );
168
+ expect(tokenRes.status).toBe(200);
169
+ const body = (await tokenRes.json()) as Record<string, unknown>;
170
+ expect(body.token_type).toBe("DPoP");
171
+ expect(body.refresh_token).toBeTruthy();
172
+
173
+ // Access token carries cnf.jkt bound to the signer key.
174
+ const access = body.access_token as string;
175
+ const payload = JSON.parse(
176
+ atob(access.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/")),
177
+ ) as Record<string, unknown>;
178
+ expect(payload.aud).toBe(RESOURCE);
179
+ expect(payload.client_id).toBe(CLIENT_ID);
180
+ expect((payload.cnf as Record<string, string>).jkt).toBeTruthy();
181
+ });
182
+
183
+ test("missing / mismatched resource is rejected with invalid_target", async () => {
184
+ const options = makeOptions();
185
+ const { challenge } = await pkcePair();
186
+ const res = await authorize(options, {
187
+ response_type: "code",
188
+ client_id: CLIENT_ID,
189
+ redirect_uri: REDIRECT,
190
+ resource: "https://other.example.com/mcp",
191
+ code_challenge: challenge,
192
+ code_challenge_method: "S256",
193
+ });
194
+ expect(res.status).toBe(302);
195
+ expect(new URL(res.location ?? "").searchParams.get("error")).toBe("invalid_target");
196
+ });
197
+
198
+ test("PKCE is mandatory and wrong verifier fails the grant", async () => {
199
+ const options = makeOptions();
200
+ const res = await authorize(options, {
201
+ response_type: "code",
202
+ client_id: CLIENT_ID,
203
+ redirect_uri: REDIRECT,
204
+ resource: RESOURCE,
205
+ });
206
+ expect(res.status).toBe(302);
207
+ expect(new URL(res.location ?? "").searchParams.get("error")).toBe("invalid_request");
208
+
209
+ // Now a valid authorize but bad verifier at token time.
210
+ const { verifier, challenge } = await pkcePair();
211
+ seedConsent(options, "mcp:tools");
212
+ const ok = await authorize(options, {
213
+ response_type: "code",
214
+ client_id: CLIENT_ID,
215
+ redirect_uri: REDIRECT,
216
+ resource: RESOURCE,
217
+ code_challenge: challenge,
218
+ code_challenge_method: "S256",
219
+ });
220
+ const code = new URL(ok.location ?? "").searchParams.get("code") ?? "";
221
+ const signer = await createDpopSigner();
222
+ const proof = await signer.prove({
223
+ htm: "POST",
224
+ htu: "https://as.example.com/oauth/token",
225
+ now: T0,
226
+ });
227
+ const tokenRes = await postToken(
228
+ options,
229
+ {
230
+ grant_type: "authorization_code",
231
+ code,
232
+ client_id: CLIENT_ID,
233
+ redirect_uri: REDIRECT,
234
+ code_verifier: `${verifier}-tampered`,
235
+ resource: RESOURCE,
236
+ },
237
+ proof,
238
+ );
239
+ expect(tokenRes.status).toBe(400);
240
+ expect(((await tokenRes.json()) as Record<string, string>).error).toBe("invalid_grant");
241
+ });
242
+
243
+ test("token without DPoP proof is rejected", async () => {
244
+ const options = makeOptions();
245
+ const { verifier, challenge } = await pkcePair();
246
+ const ok = await authorize(options, {
247
+ response_type: "code",
248
+ client_id: CLIENT_ID,
249
+ redirect_uri: REDIRECT,
250
+ resource: RESOURCE,
251
+ code_challenge: challenge,
252
+ code_challenge_method: "S256",
253
+ });
254
+ const code = new URL(ok.location ?? "").searchParams.get("code") ?? "";
255
+ const res = await postToken(options, {
256
+ grant_type: "authorization_code",
257
+ code,
258
+ client_id: CLIENT_ID,
259
+ redirect_uri: REDIRECT,
260
+ code_verifier: verifier,
261
+ resource: RESOURCE,
262
+ });
263
+ expect(res.status).toBe(400);
264
+ expect(((await res.json()) as Record<string, string>).error).toBe("invalid_request");
265
+ });
266
+
267
+ test("authorization code is single-use (replay → invalid_grant)", async () => {
268
+ const options = makeOptions();
269
+ seedConsent(options, "mcp:tools");
270
+ const { verifier, challenge } = await pkcePair();
271
+ const ok = await authorize(options, {
272
+ response_type: "code",
273
+ client_id: CLIENT_ID,
274
+ redirect_uri: REDIRECT,
275
+ resource: RESOURCE,
276
+ code_challenge: challenge,
277
+ code_challenge_method: "S256",
278
+ });
279
+ const code = new URL(ok.location ?? "").searchParams.get("code") ?? "";
280
+ const signer = await createDpopSigner();
281
+ const form = {
282
+ grant_type: "authorization_code",
283
+ code,
284
+ client_id: CLIENT_ID,
285
+ redirect_uri: REDIRECT,
286
+ code_verifier: verifier,
287
+ resource: RESOURCE,
288
+ };
289
+ const first = await postToken(
290
+ options,
291
+ form,
292
+ await signer.prove({ htm: "POST", htu: "https://as.example.com/oauth/token", now: T0 }),
293
+ );
294
+ expect(first.status).toBe(200);
295
+ const second = await postToken(
296
+ options,
297
+ form,
298
+ await signer.prove({ htm: "POST", htu: "https://as.example.com/oauth/token", now: T0 }),
299
+ );
300
+ expect(second.status).toBe(400);
301
+ expect(((await second.json()) as Record<string, string>).error).toBe("invalid_grant");
302
+ });
303
+
304
+ test("unconsented user is parked to consent screen; approve mints code", async () => {
305
+ const options = makeOptions();
306
+ const facade = createOauthAs(options);
307
+ const { verifier, challenge } = await pkcePair();
308
+ const authRes = await authorize(options, {
309
+ response_type: "code",
310
+ client_id: CLIENT_ID,
311
+ redirect_uri: REDIRECT,
312
+ scope: "mcp:tools profile",
313
+ resource: RESOURCE,
314
+ code_challenge: challenge,
315
+ code_challenge_method: "S256",
316
+ });
317
+ // First run has no consent row → parked at consent screen.
318
+ const pendingId = new URL(authRes.location ?? "").searchParams.get("consent");
319
+ expect(pendingId).toBeTruthy();
320
+
321
+ const description = facade.describeConsent(pendingId ?? "");
322
+ expect(description.clientName).toBe("Test Agent");
323
+ expect([...description.scope]).toEqual(["mcp:tools", "profile"]);
324
+
325
+ const approved = await facade.approveConsent(pendingId ?? "", "user-1");
326
+ const code = new URL(approved.redirectTo).searchParams.get("code");
327
+ expect(code).toBeTruthy();
328
+
329
+ // Wrong user cannot approve someone else's consent.
330
+ const auth2 = await authorize(options, {
331
+ response_type: "code",
332
+ client_id: CLIENT_ID,
333
+ redirect_uri: REDIRECT,
334
+ scope: "openid",
335
+ resource: RESOURCE,
336
+ code_challenge: challenge,
337
+ code_challenge_method: "S256",
338
+ });
339
+ const pending2 = new URL(auth2.location ?? "").searchParams.get("consent") ?? "";
340
+ expect(() => facade.approveConsent(pending2, "attacker-1")).toThrow(OAuthError);
341
+
342
+ void verifier;
343
+ });
344
+
345
+ test("refresh rotation: reuse burns the whole family", async () => {
346
+ const options = makeOptions();
347
+ seedConsent(options, "mcp:tools");
348
+ const { verifier, challenge } = await pkcePair();
349
+ const ok = await authorize(options, {
350
+ response_type: "code",
351
+ client_id: CLIENT_ID,
352
+ redirect_uri: REDIRECT,
353
+ resource: RESOURCE,
354
+ code_challenge: challenge,
355
+ code_challenge_method: "S256",
356
+ });
357
+ const code = new URL(ok.location ?? "").searchParams.get("code") ?? "";
358
+ const signer = await createDpopSigner();
359
+ const prove = () =>
360
+ signer.prove({ htm: "POST", htu: "https://as.example.com/oauth/token", now: T0 });
361
+ const first = (await (
362
+ await postToken(
363
+ options,
364
+ {
365
+ grant_type: "authorization_code",
366
+ code,
367
+ client_id: CLIENT_ID,
368
+ redirect_uri: REDIRECT,
369
+ code_verifier: verifier,
370
+ resource: RESOURCE,
371
+ },
372
+ await prove(),
373
+ )
374
+ ).json()) as Record<string, string>;
375
+
376
+ const refreshForm = {
377
+ grant_type: "refresh_token",
378
+ refresh_token: first.refresh_token!,
379
+ client_id: CLIENT_ID,
380
+ resource: RESOURCE,
381
+ };
382
+ const rotated = await postToken(options, refreshForm, await prove());
383
+ expect(rotated.status).toBe(200);
384
+ const rotatedBody = (await rotated.json()) as Record<string, string>;
385
+ expect(rotatedBody.refresh_token).not.toBe(first.refresh_token);
386
+
387
+ // Replaying the OLD refresh token revokes the family.
388
+ const replay = await postToken(options, refreshForm, await prove());
389
+ expect(replay.status).toBe(400);
390
+ const afterRevoke = await postToken(
391
+ options,
392
+ {
393
+ grant_type: "refresh_token",
394
+ refresh_token: rotatedBody.refresh_token!,
395
+ client_id: CLIENT_ID,
396
+ resource: RESOURCE,
397
+ },
398
+ await prove(),
399
+ );
400
+ expect(afterRevoke.status).toBe(400);
401
+ expect(((await afterRevoke.json()) as Record<string, string>).error).toBe("invalid_grant");
402
+ });
403
+
404
+ test("redirect_uri must be exactly registered", async () => {
405
+ const options = makeOptions();
406
+ const { challenge } = await pkcePair();
407
+ const res = await authorize(options, {
408
+ response_type: "code",
409
+ client_id: CLIENT_ID,
410
+ redirect_uri: "https://app.example.com/callback/",
411
+ resource: RESOURCE,
412
+ code_challenge: challenge,
413
+ code_challenge_method: "S256",
414
+ });
415
+ // Not a registered exact match → generic 400 JSON (no redirect).
416
+ expect(res.status).toBe(400);
417
+ expect(res.body).toBeDefined();
418
+ });
419
+ });