@volter/twin-googleoauth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +219 -0
  2. package/client/googleoauth-consent.css +207 -0
  3. package/client/googleoauth-consent.tsx +286 -0
  4. package/dist/client/googleoauth-consent.bundle.js +237 -0
  5. package/dist/client/googleoauth-consent.css +207 -0
  6. package/dist/client/googleoauth-consent.d.ts +88 -0
  7. package/dist/client/googleoauth-consent.js +94 -0
  8. package/dist/client/googleoauth-consent.tsx +286 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +42 -0
  11. package/dist/src/googleoauth-autherror.d.ts +25 -0
  12. package/dist/src/googleoauth-autherror.js +144 -0
  13. package/dist/src/googleoauth-budget.d.ts +48 -0
  14. package/dist/src/googleoauth-budget.js +121 -0
  15. package/dist/src/googleoauth-capabilities.d.ts +3 -0
  16. package/dist/src/googleoauth-capabilities.js +1651 -0
  17. package/dist/src/googleoauth-conformance.d.ts +10 -0
  18. package/dist/src/googleoauth-conformance.js +426 -0
  19. package/dist/src/googleoauth-connector.d.ts +70 -0
  20. package/dist/src/googleoauth-connector.js +244 -0
  21. package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
  22. package/dist/src/googleoauth-consent-client.gen.js +10 -0
  23. package/dist/src/googleoauth-consent-ui.d.ts +25 -0
  24. package/dist/src/googleoauth-consent-ui.js +102 -0
  25. package/dist/src/googleoauth-jwt.d.ts +78 -0
  26. package/dist/src/googleoauth-jwt.js +183 -0
  27. package/dist/src/googleoauth-scopes.d.ts +36 -0
  28. package/dist/src/googleoauth-scopes.js +92 -0
  29. package/dist/src/googleoauth-server.d.ts +34 -0
  30. package/dist/src/googleoauth-server.js +89 -0
  31. package/dist/src/googleoauth-store.d.ts +78 -0
  32. package/dist/src/googleoauth-store.js +313 -0
  33. package/dist/src/googleoauth-twin.d.ts +53 -0
  34. package/dist/src/googleoauth-twin.js +1050 -0
  35. package/dist/src/index.d.ts +16 -0
  36. package/dist/src/index.js +102 -0
  37. package/package.json +75 -0
  38. package/src/cli.ts +41 -0
  39. package/src/googleoauth-autherror.ts +150 -0
  40. package/src/googleoauth-budget.ts +147 -0
  41. package/src/googleoauth-capabilities.ts +1775 -0
  42. package/src/googleoauth-conformance.ts +472 -0
  43. package/src/googleoauth-connector.ts +266 -0
  44. package/src/googleoauth-consent-client.gen.ts +10 -0
  45. package/src/googleoauth-consent-ui.ts +124 -0
  46. package/src/googleoauth-journey.uitest.ts +296 -0
  47. package/src/googleoauth-jwt.ts +207 -0
  48. package/src/googleoauth-scopes.ts +109 -0
  49. package/src/googleoauth-server.ts +101 -0
  50. package/src/googleoauth-store.ts +359 -0
  51. package/src/googleoauth-twin.ts +1207 -0
  52. package/src/index.ts +175 -0
@@ -0,0 +1,472 @@
1
+ // Google OAuth conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // ── THIS CHECK DRIVES THE ROUTER AND ASSERTS WHAT CAME BACK ─────────────────────────────────
4
+ // Held to ADDING_A_TWIN.md §6's bar, and to the two escalating false-greens two packs' §9 reviews
5
+ // found there:
6
+ // • two constants asserting about each other is not a check (tinybird round one) — so every
7
+ // expectation below is a LITERAL, never a value read back out of the handler's own module;
8
+ // • "not the router's own miss" has teeth at the dispatch and nowhere deeper (tinybird round
9
+ // two) — so each probe declares the STATUS SET and a PREDICATE over the body that a live
10
+ // handler produces, and the endpoint census is a two-way bijection with the snapshot.
11
+ //
12
+ // And the whole point of this pack — the ROUND TRIP — is checked as a round trip: a real
13
+ // authorization request, a real consent, a real 302 carrying a real code, and that code redeemed
14
+ // for a real token whose id_token verifies against the JWKS this same twin serves. A router that
15
+ // dispatches every route while folding nothing cannot pass that.
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { encodeAuthError } from './googleoauth-autherror.ts';
20
+ import { verifyJwtWithJwks, type Jwks } from './googleoauth-jwt.ts';
21
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, defaultRedirectUris } from './googleoauth-store.ts';
22
+ import { googleOAuthTwinSnapshot, handleGoogleOAuthTwinRequest, type GoogleOAuthResponse } from './googleoauth-twin.ts';
23
+
24
+ export type GoogleOAuthConformanceReport = {
25
+ ok: boolean;
26
+ endpointsChecked: number;
27
+ endpointsProbed: number;
28
+ resourceTypesChecked: number;
29
+ violations: string[];
30
+ };
31
+
32
+ type Probe = {
33
+ method: string;
34
+ path: string;
35
+ body?: string;
36
+ headers?: Record<string, string>;
37
+ /** The status(es) a WORKING handler answers with. */
38
+ status: number[];
39
+ /** What a working handler's body must look like. */
40
+ expect?: (body: unknown) => boolean;
41
+ };
42
+
43
+ const isObject = (b: unknown): b is Record<string, unknown> => !!b && typeof b === 'object';
44
+ const hasKeys = (...keys: string[]) => (b: unknown) => isObject(b) && keys.every((k) => b[k] !== undefined);
45
+ const htmlContaining = (...needles: string[]) => (b: unknown) => typeof b === 'string' && needles.every((n) => b.includes(n));
46
+
47
+ /** The origin this harness's world serves the twin at. The seeded demo client's callbacks are
48
+ * DERIVED from it (runtime contract R7: the port belongs to the caller's world, never to the
49
+ * twin's source), so the probes below name no port of their own. */
50
+ const DEMO_ORIGIN = 'http://localhost:3000';
51
+ const REDIRECT_URI = defaultRedirectUris(DEMO_ORIGIN)[0]!;
52
+ const SCOPE = 'openid email profile';
53
+ const AT = '2026-02-01T00:00:00.000Z';
54
+
55
+ /** Placeholders substituted with values the live flow actually minted. */
56
+ const CODE = 'PROBE_CODE';
57
+ const ACCESS = 'PROBE_ACCESS_TOKEN';
58
+ const REFRESH = 'PROBE_REFRESH_TOKEN';
59
+
60
+ const authQuery = `client_id=${encodeURIComponent(DEFAULT_CLIENT_ID)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`
61
+ + `&response_type=code&scope=${encodeURIComponent(SCOPE)}&state=probe-state&access_type=offline&prompt=consent`;
62
+
63
+ const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
64
+
65
+ /** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
66
+ const PROBES: Record<string, Probe> = {
67
+ 'GET /.well-known/openid-configuration': {
68
+ method: 'GET',
69
+ path: '/.well-known/openid-configuration',
70
+ status: [200],
71
+ // Literals, deliberately: asserting against the handler's own constants would be the tautology
72
+ // §6 names. These are the values Google's real discovery document carries.
73
+ expect: (b) =>
74
+ isObject(b)
75
+ && b.issuer === 'https://accounts.google.com'
76
+ && String(b.authorization_endpoint).endsWith('/o/oauth2/v2/auth')
77
+ && String(b.token_endpoint).endsWith('/token')
78
+ && String(b.jwks_uri).endsWith('/oauth2/v3/certs')
79
+ && Array.isArray(b.id_token_signing_alg_values_supported)
80
+ && (b.id_token_signing_alg_values_supported as string[]).includes('RS256')
81
+ && Array.isArray(b.code_challenge_methods_supported)
82
+ && (b.code_challenge_methods_supported as string[]).includes('S256'),
83
+ },
84
+ 'GET /oauth2/v3/certs': {
85
+ method: 'GET',
86
+ path: '/oauth2/v3/certs',
87
+ status: [200],
88
+ expect: (b) => {
89
+ if (!isObject(b) || !Array.isArray(b.keys) || b.keys.length !== 1) return false;
90
+ const k = b.keys[0] as Record<string, unknown>;
91
+ return k.kty === 'RSA' && k.alg === 'RS256' && k.use === 'sig' && typeof k.n === 'string' && (k.n as string).length > 300 && k.e === 'AQAB';
92
+ },
93
+ },
94
+ 'GET /oauth2/v1/certs': {
95
+ method: 'GET',
96
+ path: '/oauth2/v1/certs',
97
+ status: [200],
98
+ expect: (b) => isObject(b) && Object.values(b).every((v) => typeof v === 'string' && (v as string).includes('-----BEGIN PUBLIC KEY-----')) && Object.keys(b).length === 1,
99
+ },
100
+ 'GET /o/oauth2/v2/auth': {
101
+ method: 'GET',
102
+ path: `/o/oauth2/v2/auth?${authQuery}`,
103
+ status: [200],
104
+ // The consent surface is HTML, and its content is the claim: the app name from the client
105
+ // registry and every seeded persona's e-mail address must be ON the page.
106
+ expect: htmlContaining('Choose an account', 'Twin Demo App', DEFAULT_ACCOUNTS[0]!.email, DEFAULT_ACCOUNTS[1]!.email),
107
+ },
108
+ 'GET /o/oauth2/auth': {
109
+ method: 'GET',
110
+ path: `/o/oauth2/auth?${authQuery}`,
111
+ status: [200],
112
+ expect: htmlContaining('Choose an account', 'Twin Demo App'),
113
+ },
114
+ 'GET /o/oauth2/v2/auth/oauthchooseaccount': {
115
+ method: 'GET',
116
+ path: `/o/oauth2/v2/auth/oauthchooseaccount?${authQuery}`,
117
+ status: [200],
118
+ expect: htmlContaining('Choose an account', 'Twin Demo App'),
119
+ },
120
+ 'GET /signin/oauth/error': {
121
+ method: 'GET',
122
+ // The page every authorization failure is bounced to, probed with a payload built HERE rather
123
+ // than one the handler produced — so this cannot become the twin agreeing with itself.
124
+ path: `/signin/oauth/error?authError=${encodeAuthError({ code: 'invalid_client', message: 'The OAuth client was not found.', status: 401 })}&flowName=GeneralOAuthFlow`,
125
+ status: [200],
126
+ expect: htmlContaining('Error 401', 'invalid_client', 'The OAuth client was not found.', 'Access blocked: Authorization Error'),
127
+ },
128
+ 'POST /token': {
129
+ method: 'POST',
130
+ path: '/token',
131
+ body: form({ grant_type: 'authorization_code', code: CODE, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }),
132
+ status: [200],
133
+ expect: (b) =>
134
+ isObject(b)
135
+ && typeof b.access_token === 'string' && (b.access_token as string).startsWith('ya29.')
136
+ && b.token_type === 'Bearer'
137
+ && b.expires_in === 3600
138
+ && typeof b.id_token === 'string' && (b.id_token as string).split('.').length === 3
139
+ && typeof b.refresh_token === 'string' && (b.refresh_token as string).startsWith('1//'),
140
+ },
141
+ 'POST /oauth2/v4/token': {
142
+ method: 'POST',
143
+ path: '/oauth2/v4/token',
144
+ // The LEGACY token path, probed through the grant that needs no minted fixture. A signed
145
+ // assertion is not required: the twin decodes but never verifies it (it authenticates nobody).
146
+ body: form({
147
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
148
+ assertion: `e30.${Buffer.from(JSON.stringify({ iss: 'probe@twin.iam.gserviceaccount.com', scope: 'https://www.googleapis.com/auth/cloud-platform' })).toString('base64url')}.x`,
149
+ }),
150
+ status: [200],
151
+ expect: (b) => isObject(b) && typeof b.access_token === 'string' && b.token_type === 'Bearer' && b.expires_in === 3600,
152
+ },
153
+ 'POST /o/oauth2/token': {
154
+ method: 'POST',
155
+ // The accounts.google.com legacy token path (the injector claims it, so the twin must serve it).
156
+ // Probed through the grant that needs no minted fixture.
157
+ path: '/o/oauth2/token',
158
+ body: form({
159
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
160
+ assertion: `e30.${Buffer.from(JSON.stringify({ iss: 'probe@twin.iam.gserviceaccount.com' })).toString('base64url')}.x`,
161
+ }),
162
+ status: [200],
163
+ expect: (b) => isObject(b) && typeof b.access_token === 'string' && b.token_type === 'Bearer',
164
+ },
165
+ 'POST /v1/userinfo': {
166
+ method: 'POST',
167
+ path: '/v1/userinfo',
168
+ body: '',
169
+ headers: { authorization: `Bearer ${ACCESS}` },
170
+ status: [200],
171
+ expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0]!.sub && b.email === DEFAULT_ACCOUNTS[0]!.email,
172
+ },
173
+ 'POST /revoke': {
174
+ method: 'POST',
175
+ path: '/revoke',
176
+ body: `token=${REFRESH}`,
177
+ status: [200],
178
+ expect: (b) => isObject(b) && Object.keys(b).length === 0,
179
+ },
180
+ 'GET /revoke': {
181
+ method: 'GET',
182
+ path: `/revoke?token=${ACCESS}`,
183
+ status: [200],
184
+ expect: (b) => isObject(b) && Object.keys(b).length === 0,
185
+ },
186
+ 'GET /tokeninfo': {
187
+ method: 'GET',
188
+ path: `/tokeninfo?access_token=${ACCESS}`,
189
+ status: [200],
190
+ expect: (b) =>
191
+ isObject(b)
192
+ && b.aud === DEFAULT_CLIENT_ID
193
+ && b.azp === DEFAULT_CLIENT_ID
194
+ && b.sub === DEFAULT_ACCOUNTS[0]!.sub
195
+ && b.scope === SCOPE
196
+ && b.email === DEFAULT_ACCOUNTS[0]!.email,
197
+ },
198
+ 'GET /v1/userinfo': {
199
+ method: 'GET',
200
+ path: '/v1/userinfo',
201
+ headers: { authorization: `Bearer ${ACCESS}` },
202
+ status: [200],
203
+ expect: (b) =>
204
+ isObject(b)
205
+ && b.sub === DEFAULT_ACCOUNTS[0]!.sub
206
+ && b.email === DEFAULT_ACCOUNTS[0]!.email
207
+ && b.name === DEFAULT_ACCOUNTS[0]!.name
208
+ && b.email_verified === true,
209
+ },
210
+ 'GET /oauth2/v3/userinfo': {
211
+ method: 'GET',
212
+ path: '/oauth2/v3/userinfo',
213
+ headers: { authorization: `Bearer ${ACCESS}` },
214
+ status: [200],
215
+ expect: hasKeys('sub', 'email', 'name'),
216
+ },
217
+ 'POST /oauth2/v3/userinfo': {
218
+ method: 'POST',
219
+ path: '/oauth2/v3/userinfo',
220
+ body: '',
221
+ headers: { authorization: `Bearer ${ACCESS}` },
222
+ status: [200],
223
+ expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0]!.sub,
224
+ },
225
+ };
226
+
227
+ /**
228
+ * Every method/path pair a reader of `routeGoogleOAuthTwinRequest` can see the router branch on,
229
+ * INCLUDING the ones the snapshot is not expected to claim. Written by hand from the router, so the
230
+ * census can catch surface that is served without being claimed — the one direction the
231
+ * probes⇄snapshot bijection is blind to.
232
+ */
233
+ const ROUTER_SURFACE: Array<[string, string]> = [
234
+ ['GET', '/.well-known/openid-configuration'],
235
+ ['GET', '/oauth2/v3/certs'],
236
+ ['GET', '/oauth2/v1/certs'],
237
+ ['GET', '/oauth2/v2/certs'],
238
+ ['GET', `/signin/oauth/error?authError=${encodeAuthError({ code: 'invalid_request', message: 'probe', status: 400 })}`],
239
+ ['GET', `/o/oauth2/v2/auth?${authQuery}`],
240
+ ['GET', `/o/oauth2/auth?${authQuery}`],
241
+ ['GET', `/o/oauth2/v2/auth/oauthchooseaccount?${authQuery}`],
242
+ ['GET', `/o/oauth2/auth/oauthchooseaccount?${authQuery}`],
243
+ ['POST', '/token'],
244
+ ['POST', '/oauth2/v4/token'],
245
+ ['POST', '/o/oauth2/token'],
246
+ ['POST', '/revoke'],
247
+ ['GET', '/revoke'],
248
+ ['GET', '/o/oauth2/revoke'],
249
+ ['GET', '/tokeninfo'],
250
+ ['GET', '/v1/userinfo'],
251
+ ['POST', '/v1/userinfo'],
252
+ ['GET', '/oauth2/v3/userinfo'],
253
+ ['POST', '/oauth2/v3/userinfo'],
254
+ ['GET', '/oauth2/v2/userinfo'],
255
+ ['GET', '/device/code'],
256
+ ['POST', '/device/code'],
257
+ ];
258
+
259
+ export async function checkGoogleOAuthConformance(opts: { root?: string } = {}): Promise<GoogleOAuthConformanceReport> {
260
+ const snapshot = googleOAuthTwinSnapshot();
261
+ const violations: string[] = [];
262
+ // Always a THROWAWAY root, even when a caller passes one: the check mints and then REVOKES
263
+ // credentials, and doing that in an operator's world would log them out of their own twin.
264
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-conformance-'));
265
+ const call = (method: string, path: string, body?: string, headers?: Record<string, string>): Promise<GoogleOAuthResponse> =>
266
+ handleGoogleOAuthTwinRequest({ method, path, ...(body !== undefined ? { body } : {}), ...(headers ? { headers } : {}), root, origin: DEMO_ORIGIN, occurredAt: AT });
267
+
268
+ let probed = 0;
269
+ try {
270
+ // ── the ROUND TRIP, driven for real, before any probing ──
271
+ const authRes = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
272
+ const requestId = /name="auth_request" value="([^"]+)"/.exec(String(authRes.body))?.[1] ?? '';
273
+ if (!requestId) violations.push('the authorization endpoint did not render a consent form carrying an auth_request handle');
274
+ const sub = DEFAULT_ACCOUNTS[0]!.sub;
275
+ const decision = await call(
276
+ 'POST',
277
+ '/_twin/consent',
278
+ form({ auth_request: requestId, sub, decision: 'allow' }),
279
+ );
280
+ const location = decision.headers?.location ?? '';
281
+ if (decision.status !== 302 || !location.startsWith(REDIRECT_URI)) {
282
+ violations.push(`consent did not 302 back to the registered redirect_uri: ${decision.status} ${location}`);
283
+ }
284
+ const back = new URL(location || 'http://invalid.test/');
285
+ const code = back.searchParams.get('code') ?? '';
286
+ if (back.searchParams.get('state') !== 'probe-state') {
287
+ violations.push(`the redirect did not echo the caller's state verbatim: ${back.searchParams.get('state')}`);
288
+ }
289
+ if (!code.startsWith('4/0A')) violations.push(`the redirect did not carry a Google-shaped authorization code: ${code}`);
290
+
291
+ const tokenRes = await call(
292
+ 'POST',
293
+ '/token',
294
+ form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }),
295
+ );
296
+ const tokens = tokenRes.body as Record<string, any>;
297
+ if (tokenRes.status !== 200 || typeof tokens?.access_token !== 'string') {
298
+ violations.push(`the minted code was not redeemable at the token endpoint: ${tokenRes.status} ${JSON.stringify(tokenRes.body).slice(0, 160)}`);
299
+ }
300
+ // …and the id_token is REAL: it verifies against the JWKS this same twin serves.
301
+ const jwksRes = await call('GET', '/oauth2/v3/certs');
302
+ const verified = typeof tokens?.id_token === 'string'
303
+ ? verifyJwtWithJwks(tokens.id_token, jwksRes.body as Jwks, { now: Math.floor(Date.parse(AT) / 1000) + 10 })
304
+ : { valid: false, reason: 'no id_token' };
305
+ if (!verified.valid) violations.push(`the id_token does not verify against the served JWKS: ${verified.reason}`);
306
+ if (verified.valid && verified.payload?.iss !== 'https://accounts.google.com') {
307
+ violations.push(`the id_token issuer is not Google's: ${String(verified.payload?.iss)}`);
308
+ }
309
+ if (verified.valid && verified.payload?.sub !== sub) {
310
+ violations.push(`the id_token subject is not the consenting account: ${String(verified.payload?.sub)}`);
311
+ }
312
+ // A redeemed code must be DEAD. This is the security property, so it is checked here and not
313
+ // only in the manifest.
314
+ const replay = await call(
315
+ 'POST',
316
+ '/token',
317
+ form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }),
318
+ );
319
+ if (replay.status !== 400 || (replay.body as Record<string, unknown>)?.error !== 'invalid_grant') {
320
+ violations.push(`a replayed authorization code was not refused with invalid_grant: ${replay.status} ${JSON.stringify(replay.body).slice(0, 120)}`);
321
+ }
322
+
323
+ // ── the endpoint census, THREE ways ──
324
+ // The probe table and the snapshot are checked against each other below, but that pair
325
+ // structurally cannot see surface the ROUTER serves and the snapshot never claimed — which is
326
+ // how four endpoints stayed deletable through §9 round one, and how a fifth
327
+ // (`POST /oauth2/v3/userinfo`) was still missing after it. So the router is enumerated too:
328
+ // every method/path pair that answers anything but the not-found envelope must be claimed.
329
+ const claimed = new Set(snapshot.implementedEndpoints);
330
+ for (const key of Object.keys(PROBES)) {
331
+ if (!claimed.has(key)) violations.push(`probe '${key}' does not correspond to any claimed endpoint — the probe table has drifted`);
332
+ }
333
+ for (const [method, probePath] of ROUTER_SURFACE) {
334
+ const res = await call(method, probePath);
335
+ const notFound = res.status === 404 && (res.body as { error?: { status?: string } } | null)?.error?.status === 'NOT_FOUND';
336
+ const key = `${method} ${probePath.split('?')[0]}`;
337
+ if (!notFound && !claimed.has(key)) {
338
+ violations.push(`the router answers '${key}' (${res.status}) but the snapshot does not claim it — served surface outside the census is deletable without this check noticing`);
339
+ }
340
+ }
341
+
342
+ // Each destructive probe needs its own fixtures, so probe ORDER cannot make this check lie:
343
+ // `/revoke` kills the WHOLE grant, which would take the tokeninfo/userinfo probes' token with
344
+ // it. Mint one throwaway grant per revoke probe instead of sharing the round-trip's.
345
+ const throwaway = async (): Promise<{ access: string; refresh: string }> => {
346
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
347
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
348
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub: DEFAULT_ACCOUNTS[1]!.sub, decision: 'allow' }));
349
+ const c = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
350
+ const t = await call('POST', '/token', form({ grant_type: 'authorization_code', code: c, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }));
351
+ const b = t.body as Record<string, any>;
352
+ return { access: String(b.access_token ?? ''), refresh: String(b.refresh_token ?? '') };
353
+ };
354
+ const revokeFixtureA = await throwaway();
355
+ const revokeFixtureB = await throwaway();
356
+ // A FRESH code for the `POST /token` probe, since the round-trip's is now consumed.
357
+ const probeAuth = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
358
+ const probeRid = /name="auth_request" value="([^"]+)"/.exec(String(probeAuth.body))?.[1] ?? '';
359
+ const probeDecision = await call('POST', '/_twin/consent', form({ auth_request: probeRid, sub, decision: 'allow' }));
360
+ const probeCode = new URL(probeDecision.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
361
+
362
+ const substitute = (s: string) =>
363
+ s.replace(CODE, probeCode).replace(ACCESS, String(tokens?.access_token ?? '')).replace(REFRESH, String(tokens?.refresh_token ?? ''));
364
+
365
+ for (const endpoint of snapshot.implementedEndpoints) {
366
+ const probe = PROBES[endpoint];
367
+ if (!probe) {
368
+ violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`);
369
+ continue;
370
+ }
371
+ const [claimedMethod, claimedPath] = endpoint.split(' ');
372
+ if (probe.method !== claimedMethod) {
373
+ violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`);
374
+ continue;
375
+ }
376
+ if ((probe.path.split('?')[0] ?? '') !== claimedPath) {
377
+ violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
378
+ continue;
379
+ }
380
+ // The two revoke probes get their own grant so they cannot disarm each other's fixtures.
381
+ const path = endpoint === 'POST /revoke'
382
+ ? probe.path
383
+ : endpoint === 'GET /revoke'
384
+ ? probe.path.replace(ACCESS, revokeFixtureB.access)
385
+ : substitute(probe.path);
386
+ const body = endpoint === 'POST /revoke' ? `token=${revokeFixtureA.refresh}` : probe.body === undefined ? undefined : substitute(probe.body);
387
+ const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
388
+ const res = await call(probe.method, path, body, headers);
389
+ probed += 1;
390
+ if (!probe.status.includes(res.status)) {
391
+ violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
392
+ continue;
393
+ }
394
+ if (probe.expect && !probe.expect(res.body)) {
395
+ violations.push(`endpoint '${endpoint}' answered ${res.status} but the body is not the shape this route returns: ${JSON.stringify(res.body).slice(0, 200)}`);
396
+ }
397
+ }
398
+
399
+ // Every grant type the snapshot claims must actually dispatch — an unsupported one answers
400
+ // `unsupported_grant_type`, so a claimed-but-missing grant is caught by name.
401
+ for (const grantType of snapshot.grantTypes) {
402
+ const res = await call('POST', '/token', form({ grant_type: grantType }));
403
+ const err = (res.body as Record<string, unknown> | null)?.error;
404
+ if (err === 'unsupported_grant_type') violations.push(`grant type '${grantType}' is claimed but the token endpoint does not dispatch it`);
405
+ }
406
+
407
+ // Every resource type the twin projects must be reachable through the protocol — a type with no
408
+ // endpoint is state a consumer can never see.
409
+ //
410
+ // Checked against a REAL RESPONSE, not against the snapshot. An earlier version asked
411
+ // `claimed.has('GET /tokeninfo')` for each type, which is the snapshot asserting about itself —
412
+ // exactly the two-constants tautology this file's header disavows, and §9 round one caught it
413
+ // sitting three lines under that header. Each probe below reads a value only a live handler
414
+ // that genuinely projects that resource type can produce.
415
+ const witness: Record<string, () => Promise<boolean>> = {
416
+ // the app's registered NAME is only on the screen if the oauth_client row projected
417
+ oauth_client: async () => String((await call('GET', `/o/oauth2/v2/auth?${authQuery}`)).body).includes('Twin Demo App'),
418
+ // the persona's e-mail comes back only if the account row projected — so READ it, rather than
419
+ // grading a bare 200 while the comment claims otherwise (§9 round two)
420
+ account: async () => {
421
+ const r = await call('GET', '/v1/userinfo', undefined, { authorization: `Bearer ${tokens?.access_token}` });
422
+ return r.status === 200 && (r.body as Record<string, unknown>)?.email === DEFAULT_ACCOUNTS[0]!.email;
423
+ },
424
+ // an auth_request that did not project could not have carried a settleable handle
425
+ auth_request: async () => /name="auth_request" value="ar_[0-9a-f]{32}"/.test(String((await call('GET', `/o/oauth2/v2/auth?${authQuery}`)).body)),
426
+ // A code that did not project could not be REDEEMED. The witness has to be the SUCCESS, not
427
+ // the refusal: the handler deliberately answers `invalid_grant` for unknown / consumed /
428
+ // expired / wrong-client alike, so "it was refused" is exactly what a twin projecting NO
429
+ // authorization_code rows would also say — a tautology in a new costume (§9 round two).
430
+ authorization_code: async () => {
431
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
432
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
433
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub, decision: 'allow' }));
434
+ const fresh = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
435
+ const r = await call('POST', '/token', form({ grant_type: 'authorization_code', code: fresh, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }));
436
+ return r.status === 200 && typeof (r.body as Record<string, unknown>)?.access_token === 'string';
437
+ },
438
+ access_token: async () => (await call('GET', `/tokeninfo?access_token=${tokens?.access_token}`)).status === 200,
439
+ refresh_token: async () => (await call('POST', '/token', form({ grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token), client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET }))).status === 200,
440
+ // the grant row is what makes a SECOND offline authorization withhold a refresh token
441
+ grant: async () => {
442
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery.replace('&prompt=consent', '')}`);
443
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
444
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub, decision: 'allow' }));
445
+ const c = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
446
+ const t = await call('POST', '/token', form({ grant_type: 'authorization_code', code: c, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }));
447
+ const b = t.body as Record<string, unknown>;
448
+ // The SUCCESS must be asserted too: `refresh_token === undefined` alone is satisfied by any
449
+ // error body, so a failure anywhere in the four steps above read as a pass (§9 round two).
450
+ return t.status === 200 && typeof b?.access_token === 'string' && b.refresh_token === undefined;
451
+ },
452
+ };
453
+ for (const type of snapshot.resourceTypes) {
454
+ const probe = witness[type];
455
+ if (!probe) {
456
+ violations.push(`resource type '${type}' has no reachability witness — the claim is unverified`);
457
+ continue;
458
+ }
459
+ if (!(await probe())) violations.push(`resource type '${type}' is not reachable through any served endpoint`);
460
+ }
461
+ } finally {
462
+ rmSync(root, { recursive: true, force: true });
463
+ }
464
+
465
+ return {
466
+ ok: violations.length === 0,
467
+ endpointsChecked: snapshot.implementedEndpoints.length,
468
+ endpointsProbed: probed,
469
+ resourceTypesChecked: snapshot.resourceTypes.length,
470
+ violations,
471
+ };
472
+ }