@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,10 @@
1
+ export type GoogleOAuthConformanceReport = {
2
+ ok: boolean;
3
+ endpointsChecked: number;
4
+ endpointsProbed: number;
5
+ resourceTypesChecked: number;
6
+ violations: string[];
7
+ };
8
+ export declare function checkGoogleOAuthConformance(opts?: {
9
+ root?: string;
10
+ }): Promise<GoogleOAuthConformanceReport>;
@@ -0,0 +1,426 @@
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.js";
20
+ import { verifyJwtWithJwks } from "./googleoauth-jwt.js";
21
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, defaultRedirectUris } from "./googleoauth-store.js";
22
+ import { googleOAuthTwinSnapshot, handleGoogleOAuthTwinRequest } from "./googleoauth-twin.js";
23
+ const isObject = (b) => !!b && typeof b === 'object';
24
+ const hasKeys = (...keys) => (b) => isObject(b) && keys.every((k) => b[k] !== undefined);
25
+ const htmlContaining = (...needles) => (b) => typeof b === 'string' && needles.every((n) => b.includes(n));
26
+ /** The origin this harness's world serves the twin at. The seeded demo client's callbacks are
27
+ * DERIVED from it (runtime contract R7: the port belongs to the caller's world, never to the
28
+ * twin's source), so the probes below name no port of their own. */
29
+ const DEMO_ORIGIN = 'http://localhost:3000';
30
+ const REDIRECT_URI = defaultRedirectUris(DEMO_ORIGIN)[0];
31
+ const SCOPE = 'openid email profile';
32
+ const AT = '2026-02-01T00:00:00.000Z';
33
+ /** Placeholders substituted with values the live flow actually minted. */
34
+ const CODE = 'PROBE_CODE';
35
+ const ACCESS = 'PROBE_ACCESS_TOKEN';
36
+ const REFRESH = 'PROBE_REFRESH_TOKEN';
37
+ const authQuery = `client_id=${encodeURIComponent(DEFAULT_CLIENT_ID)}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`
38
+ + `&response_type=code&scope=${encodeURIComponent(SCOPE)}&state=probe-state&access_type=offline&prompt=consent`;
39
+ const form = (params) => new URLSearchParams(params).toString();
40
+ /** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
41
+ const PROBES = {
42
+ 'GET /.well-known/openid-configuration': {
43
+ method: 'GET',
44
+ path: '/.well-known/openid-configuration',
45
+ status: [200],
46
+ // Literals, deliberately: asserting against the handler's own constants would be the tautology
47
+ // §6 names. These are the values Google's real discovery document carries.
48
+ expect: (b) => isObject(b)
49
+ && b.issuer === 'https://accounts.google.com'
50
+ && String(b.authorization_endpoint).endsWith('/o/oauth2/v2/auth')
51
+ && String(b.token_endpoint).endsWith('/token')
52
+ && String(b.jwks_uri).endsWith('/oauth2/v3/certs')
53
+ && Array.isArray(b.id_token_signing_alg_values_supported)
54
+ && b.id_token_signing_alg_values_supported.includes('RS256')
55
+ && Array.isArray(b.code_challenge_methods_supported)
56
+ && b.code_challenge_methods_supported.includes('S256'),
57
+ },
58
+ 'GET /oauth2/v3/certs': {
59
+ method: 'GET',
60
+ path: '/oauth2/v3/certs',
61
+ status: [200],
62
+ expect: (b) => {
63
+ if (!isObject(b) || !Array.isArray(b.keys) || b.keys.length !== 1)
64
+ return false;
65
+ const k = b.keys[0];
66
+ return k.kty === 'RSA' && k.alg === 'RS256' && k.use === 'sig' && typeof k.n === 'string' && k.n.length > 300 && k.e === 'AQAB';
67
+ },
68
+ },
69
+ 'GET /oauth2/v1/certs': {
70
+ method: 'GET',
71
+ path: '/oauth2/v1/certs',
72
+ status: [200],
73
+ expect: (b) => isObject(b) && Object.values(b).every((v) => typeof v === 'string' && v.includes('-----BEGIN PUBLIC KEY-----')) && Object.keys(b).length === 1,
74
+ },
75
+ 'GET /o/oauth2/v2/auth': {
76
+ method: 'GET',
77
+ path: `/o/oauth2/v2/auth?${authQuery}`,
78
+ status: [200],
79
+ // The consent surface is HTML, and its content is the claim: the app name from the client
80
+ // registry and every seeded persona's e-mail address must be ON the page.
81
+ expect: htmlContaining('Choose an account', 'Twin Demo App', DEFAULT_ACCOUNTS[0].email, DEFAULT_ACCOUNTS[1].email),
82
+ },
83
+ 'GET /o/oauth2/auth': {
84
+ method: 'GET',
85
+ path: `/o/oauth2/auth?${authQuery}`,
86
+ status: [200],
87
+ expect: htmlContaining('Choose an account', 'Twin Demo App'),
88
+ },
89
+ 'GET /o/oauth2/v2/auth/oauthchooseaccount': {
90
+ method: 'GET',
91
+ path: `/o/oauth2/v2/auth/oauthchooseaccount?${authQuery}`,
92
+ status: [200],
93
+ expect: htmlContaining('Choose an account', 'Twin Demo App'),
94
+ },
95
+ 'GET /signin/oauth/error': {
96
+ method: 'GET',
97
+ // The page every authorization failure is bounced to, probed with a payload built HERE rather
98
+ // than one the handler produced — so this cannot become the twin agreeing with itself.
99
+ path: `/signin/oauth/error?authError=${encodeAuthError({ code: 'invalid_client', message: 'The OAuth client was not found.', status: 401 })}&flowName=GeneralOAuthFlow`,
100
+ status: [200],
101
+ expect: htmlContaining('Error 401', 'invalid_client', 'The OAuth client was not found.', 'Access blocked: Authorization Error'),
102
+ },
103
+ 'POST /token': {
104
+ method: 'POST',
105
+ path: '/token',
106
+ body: form({ grant_type: 'authorization_code', code: CODE, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }),
107
+ status: [200],
108
+ expect: (b) => isObject(b)
109
+ && typeof b.access_token === 'string' && b.access_token.startsWith('ya29.')
110
+ && b.token_type === 'Bearer'
111
+ && b.expires_in === 3600
112
+ && typeof b.id_token === 'string' && b.id_token.split('.').length === 3
113
+ && typeof b.refresh_token === 'string' && b.refresh_token.startsWith('1//'),
114
+ },
115
+ 'POST /oauth2/v4/token': {
116
+ method: 'POST',
117
+ path: '/oauth2/v4/token',
118
+ // The LEGACY token path, probed through the grant that needs no minted fixture. A signed
119
+ // assertion is not required: the twin decodes but never verifies it (it authenticates nobody).
120
+ body: form({
121
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
122
+ assertion: `e30.${Buffer.from(JSON.stringify({ iss: 'probe@twin.iam.gserviceaccount.com', scope: 'https://www.googleapis.com/auth/cloud-platform' })).toString('base64url')}.x`,
123
+ }),
124
+ status: [200],
125
+ expect: (b) => isObject(b) && typeof b.access_token === 'string' && b.token_type === 'Bearer' && b.expires_in === 3600,
126
+ },
127
+ 'POST /o/oauth2/token': {
128
+ method: 'POST',
129
+ // The accounts.google.com legacy token path (the injector claims it, so the twin must serve it).
130
+ // Probed through the grant that needs no minted fixture.
131
+ path: '/o/oauth2/token',
132
+ body: form({
133
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
134
+ assertion: `e30.${Buffer.from(JSON.stringify({ iss: 'probe@twin.iam.gserviceaccount.com' })).toString('base64url')}.x`,
135
+ }),
136
+ status: [200],
137
+ expect: (b) => isObject(b) && typeof b.access_token === 'string' && b.token_type === 'Bearer',
138
+ },
139
+ 'POST /v1/userinfo': {
140
+ method: 'POST',
141
+ path: '/v1/userinfo',
142
+ body: '',
143
+ headers: { authorization: `Bearer ${ACCESS}` },
144
+ status: [200],
145
+ expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0].sub && b.email === DEFAULT_ACCOUNTS[0].email,
146
+ },
147
+ 'POST /revoke': {
148
+ method: 'POST',
149
+ path: '/revoke',
150
+ body: `token=${REFRESH}`,
151
+ status: [200],
152
+ expect: (b) => isObject(b) && Object.keys(b).length === 0,
153
+ },
154
+ 'GET /revoke': {
155
+ method: 'GET',
156
+ path: `/revoke?token=${ACCESS}`,
157
+ status: [200],
158
+ expect: (b) => isObject(b) && Object.keys(b).length === 0,
159
+ },
160
+ 'GET /tokeninfo': {
161
+ method: 'GET',
162
+ path: `/tokeninfo?access_token=${ACCESS}`,
163
+ status: [200],
164
+ expect: (b) => isObject(b)
165
+ && b.aud === DEFAULT_CLIENT_ID
166
+ && b.azp === DEFAULT_CLIENT_ID
167
+ && b.sub === DEFAULT_ACCOUNTS[0].sub
168
+ && b.scope === SCOPE
169
+ && b.email === DEFAULT_ACCOUNTS[0].email,
170
+ },
171
+ 'GET /v1/userinfo': {
172
+ method: 'GET',
173
+ path: '/v1/userinfo',
174
+ headers: { authorization: `Bearer ${ACCESS}` },
175
+ status: [200],
176
+ expect: (b) => isObject(b)
177
+ && b.sub === DEFAULT_ACCOUNTS[0].sub
178
+ && b.email === DEFAULT_ACCOUNTS[0].email
179
+ && b.name === DEFAULT_ACCOUNTS[0].name
180
+ && b.email_verified === true,
181
+ },
182
+ 'GET /oauth2/v3/userinfo': {
183
+ method: 'GET',
184
+ path: '/oauth2/v3/userinfo',
185
+ headers: { authorization: `Bearer ${ACCESS}` },
186
+ status: [200],
187
+ expect: hasKeys('sub', 'email', 'name'),
188
+ },
189
+ 'POST /oauth2/v3/userinfo': {
190
+ method: 'POST',
191
+ path: '/oauth2/v3/userinfo',
192
+ body: '',
193
+ headers: { authorization: `Bearer ${ACCESS}` },
194
+ status: [200],
195
+ expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0].sub,
196
+ },
197
+ };
198
+ /**
199
+ * Every method/path pair a reader of `routeGoogleOAuthTwinRequest` can see the router branch on,
200
+ * INCLUDING the ones the snapshot is not expected to claim. Written by hand from the router, so the
201
+ * census can catch surface that is served without being claimed — the one direction the
202
+ * probes⇄snapshot bijection is blind to.
203
+ */
204
+ const ROUTER_SURFACE = [
205
+ ['GET', '/.well-known/openid-configuration'],
206
+ ['GET', '/oauth2/v3/certs'],
207
+ ['GET', '/oauth2/v1/certs'],
208
+ ['GET', '/oauth2/v2/certs'],
209
+ ['GET', `/signin/oauth/error?authError=${encodeAuthError({ code: 'invalid_request', message: 'probe', status: 400 })}`],
210
+ ['GET', `/o/oauth2/v2/auth?${authQuery}`],
211
+ ['GET', `/o/oauth2/auth?${authQuery}`],
212
+ ['GET', `/o/oauth2/v2/auth/oauthchooseaccount?${authQuery}`],
213
+ ['GET', `/o/oauth2/auth/oauthchooseaccount?${authQuery}`],
214
+ ['POST', '/token'],
215
+ ['POST', '/oauth2/v4/token'],
216
+ ['POST', '/o/oauth2/token'],
217
+ ['POST', '/revoke'],
218
+ ['GET', '/revoke'],
219
+ ['GET', '/o/oauth2/revoke'],
220
+ ['GET', '/tokeninfo'],
221
+ ['GET', '/v1/userinfo'],
222
+ ['POST', '/v1/userinfo'],
223
+ ['GET', '/oauth2/v3/userinfo'],
224
+ ['POST', '/oauth2/v3/userinfo'],
225
+ ['GET', '/oauth2/v2/userinfo'],
226
+ ['GET', '/device/code'],
227
+ ['POST', '/device/code'],
228
+ ];
229
+ export async function checkGoogleOAuthConformance(opts = {}) {
230
+ const snapshot = googleOAuthTwinSnapshot();
231
+ const violations = [];
232
+ // Always a THROWAWAY root, even when a caller passes one: the check mints and then REVOKES
233
+ // credentials, and doing that in an operator's world would log them out of their own twin.
234
+ const root = mkdtempSync(join(tmpdir(), 'googleoauth-conformance-'));
235
+ const call = (method, path, body, headers) => handleGoogleOAuthTwinRequest({ method, path, ...(body !== undefined ? { body } : {}), ...(headers ? { headers } : {}), root, origin: DEMO_ORIGIN, occurredAt: AT });
236
+ let probed = 0;
237
+ try {
238
+ // ── the ROUND TRIP, driven for real, before any probing ──
239
+ const authRes = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
240
+ const requestId = /name="auth_request" value="([^"]+)"/.exec(String(authRes.body))?.[1] ?? '';
241
+ if (!requestId)
242
+ violations.push('the authorization endpoint did not render a consent form carrying an auth_request handle');
243
+ const sub = DEFAULT_ACCOUNTS[0].sub;
244
+ const decision = await call('POST', '/_twin/consent', form({ auth_request: requestId, sub, decision: 'allow' }));
245
+ const location = decision.headers?.location ?? '';
246
+ if (decision.status !== 302 || !location.startsWith(REDIRECT_URI)) {
247
+ violations.push(`consent did not 302 back to the registered redirect_uri: ${decision.status} ${location}`);
248
+ }
249
+ const back = new URL(location || 'http://invalid.test/');
250
+ const code = back.searchParams.get('code') ?? '';
251
+ if (back.searchParams.get('state') !== 'probe-state') {
252
+ violations.push(`the redirect did not echo the caller's state verbatim: ${back.searchParams.get('state')}`);
253
+ }
254
+ if (!code.startsWith('4/0A'))
255
+ violations.push(`the redirect did not carry a Google-shaped authorization code: ${code}`);
256
+ const tokenRes = await call('POST', '/token', form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }));
257
+ const tokens = tokenRes.body;
258
+ if (tokenRes.status !== 200 || typeof tokens?.access_token !== 'string') {
259
+ violations.push(`the minted code was not redeemable at the token endpoint: ${tokenRes.status} ${JSON.stringify(tokenRes.body).slice(0, 160)}`);
260
+ }
261
+ // …and the id_token is REAL: it verifies against the JWKS this same twin serves.
262
+ const jwksRes = await call('GET', '/oauth2/v3/certs');
263
+ const verified = typeof tokens?.id_token === 'string'
264
+ ? verifyJwtWithJwks(tokens.id_token, jwksRes.body, { now: Math.floor(Date.parse(AT) / 1000) + 10 })
265
+ : { valid: false, reason: 'no id_token' };
266
+ if (!verified.valid)
267
+ violations.push(`the id_token does not verify against the served JWKS: ${verified.reason}`);
268
+ if (verified.valid && verified.payload?.iss !== 'https://accounts.google.com') {
269
+ violations.push(`the id_token issuer is not Google's: ${String(verified.payload?.iss)}`);
270
+ }
271
+ if (verified.valid && verified.payload?.sub !== sub) {
272
+ violations.push(`the id_token subject is not the consenting account: ${String(verified.payload?.sub)}`);
273
+ }
274
+ // A redeemed code must be DEAD. This is the security property, so it is checked here and not
275
+ // only in the manifest.
276
+ const replay = await call('POST', '/token', form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, client_secret: DEFAULT_CLIENT_SECRET, redirect_uri: REDIRECT_URI }));
277
+ if (replay.status !== 400 || replay.body?.error !== 'invalid_grant') {
278
+ violations.push(`a replayed authorization code was not refused with invalid_grant: ${replay.status} ${JSON.stringify(replay.body).slice(0, 120)}`);
279
+ }
280
+ // ── the endpoint census, THREE ways ──
281
+ // The probe table and the snapshot are checked against each other below, but that pair
282
+ // structurally cannot see surface the ROUTER serves and the snapshot never claimed — which is
283
+ // how four endpoints stayed deletable through §9 round one, and how a fifth
284
+ // (`POST /oauth2/v3/userinfo`) was still missing after it. So the router is enumerated too:
285
+ // every method/path pair that answers anything but the not-found envelope must be claimed.
286
+ const claimed = new Set(snapshot.implementedEndpoints);
287
+ for (const key of Object.keys(PROBES)) {
288
+ if (!claimed.has(key))
289
+ violations.push(`probe '${key}' does not correspond to any claimed endpoint — the probe table has drifted`);
290
+ }
291
+ for (const [method, probePath] of ROUTER_SURFACE) {
292
+ const res = await call(method, probePath);
293
+ const notFound = res.status === 404 && res.body?.error?.status === 'NOT_FOUND';
294
+ const key = `${method} ${probePath.split('?')[0]}`;
295
+ if (!notFound && !claimed.has(key)) {
296
+ 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`);
297
+ }
298
+ }
299
+ // Each destructive probe needs its own fixtures, so probe ORDER cannot make this check lie:
300
+ // `/revoke` kills the WHOLE grant, which would take the tokeninfo/userinfo probes' token with
301
+ // it. Mint one throwaway grant per revoke probe instead of sharing the round-trip's.
302
+ const throwaway = async () => {
303
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
304
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
305
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub: DEFAULT_ACCOUNTS[1].sub, decision: 'allow' }));
306
+ const c = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
307
+ 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 }));
308
+ const b = t.body;
309
+ return { access: String(b.access_token ?? ''), refresh: String(b.refresh_token ?? '') };
310
+ };
311
+ const revokeFixtureA = await throwaway();
312
+ const revokeFixtureB = await throwaway();
313
+ // A FRESH code for the `POST /token` probe, since the round-trip's is now consumed.
314
+ const probeAuth = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
315
+ const probeRid = /name="auth_request" value="([^"]+)"/.exec(String(probeAuth.body))?.[1] ?? '';
316
+ const probeDecision = await call('POST', '/_twin/consent', form({ auth_request: probeRid, sub, decision: 'allow' }));
317
+ const probeCode = new URL(probeDecision.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
318
+ const substitute = (s) => s.replace(CODE, probeCode).replace(ACCESS, String(tokens?.access_token ?? '')).replace(REFRESH, String(tokens?.refresh_token ?? ''));
319
+ for (const endpoint of snapshot.implementedEndpoints) {
320
+ const probe = PROBES[endpoint];
321
+ if (!probe) {
322
+ violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`);
323
+ continue;
324
+ }
325
+ const [claimedMethod, claimedPath] = endpoint.split(' ');
326
+ if (probe.method !== claimedMethod) {
327
+ violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`);
328
+ continue;
329
+ }
330
+ if ((probe.path.split('?')[0] ?? '') !== claimedPath) {
331
+ violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
332
+ continue;
333
+ }
334
+ // The two revoke probes get their own grant so they cannot disarm each other's fixtures.
335
+ const path = endpoint === 'POST /revoke'
336
+ ? probe.path
337
+ : endpoint === 'GET /revoke'
338
+ ? probe.path.replace(ACCESS, revokeFixtureB.access)
339
+ : substitute(probe.path);
340
+ const body = endpoint === 'POST /revoke' ? `token=${revokeFixtureA.refresh}` : probe.body === undefined ? undefined : substitute(probe.body);
341
+ const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
342
+ const res = await call(probe.method, path, body, headers);
343
+ probed += 1;
344
+ if (!probe.status.includes(res.status)) {
345
+ violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
346
+ continue;
347
+ }
348
+ if (probe.expect && !probe.expect(res.body)) {
349
+ violations.push(`endpoint '${endpoint}' answered ${res.status} but the body is not the shape this route returns: ${JSON.stringify(res.body).slice(0, 200)}`);
350
+ }
351
+ }
352
+ // Every grant type the snapshot claims must actually dispatch — an unsupported one answers
353
+ // `unsupported_grant_type`, so a claimed-but-missing grant is caught by name.
354
+ for (const grantType of snapshot.grantTypes) {
355
+ const res = await call('POST', '/token', form({ grant_type: grantType }));
356
+ const err = res.body?.error;
357
+ if (err === 'unsupported_grant_type')
358
+ violations.push(`grant type '${grantType}' is claimed but the token endpoint does not dispatch it`);
359
+ }
360
+ // Every resource type the twin projects must be reachable through the protocol — a type with no
361
+ // endpoint is state a consumer can never see.
362
+ //
363
+ // Checked against a REAL RESPONSE, not against the snapshot. An earlier version asked
364
+ // `claimed.has('GET /tokeninfo')` for each type, which is the snapshot asserting about itself —
365
+ // exactly the two-constants tautology this file's header disavows, and §9 round one caught it
366
+ // sitting three lines under that header. Each probe below reads a value only a live handler
367
+ // that genuinely projects that resource type can produce.
368
+ const witness = {
369
+ // the app's registered NAME is only on the screen if the oauth_client row projected
370
+ oauth_client: async () => String((await call('GET', `/o/oauth2/v2/auth?${authQuery}`)).body).includes('Twin Demo App'),
371
+ // the persona's e-mail comes back only if the account row projected — so READ it, rather than
372
+ // grading a bare 200 while the comment claims otherwise (§9 round two)
373
+ account: async () => {
374
+ const r = await call('GET', '/v1/userinfo', undefined, { authorization: `Bearer ${tokens?.access_token}` });
375
+ return r.status === 200 && r.body?.email === DEFAULT_ACCOUNTS[0].email;
376
+ },
377
+ // an auth_request that did not project could not have carried a settleable handle
378
+ auth_request: async () => /name="auth_request" value="ar_[0-9a-f]{32}"/.test(String((await call('GET', `/o/oauth2/v2/auth?${authQuery}`)).body)),
379
+ // A code that did not project could not be REDEEMED. The witness has to be the SUCCESS, not
380
+ // the refusal: the handler deliberately answers `invalid_grant` for unknown / consumed /
381
+ // expired / wrong-client alike, so "it was refused" is exactly what a twin projecting NO
382
+ // authorization_code rows would also say — a tautology in a new costume (§9 round two).
383
+ authorization_code: async () => {
384
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery}`);
385
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
386
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub, decision: 'allow' }));
387
+ const fresh = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
388
+ 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 }));
389
+ return r.status === 200 && typeof r.body?.access_token === 'string';
390
+ },
391
+ access_token: async () => (await call('GET', `/tokeninfo?access_token=${tokens?.access_token}`)).status === 200,
392
+ 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,
393
+ // the grant row is what makes a SECOND offline authorization withhold a refresh token
394
+ grant: async () => {
395
+ const a = await call('GET', `/o/oauth2/v2/auth?${authQuery.replace('&prompt=consent', '')}`);
396
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
397
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, sub, decision: 'allow' }));
398
+ const c = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
399
+ 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 }));
400
+ const b = t.body;
401
+ // The SUCCESS must be asserted too: `refresh_token === undefined` alone is satisfied by any
402
+ // error body, so a failure anywhere in the four steps above read as a pass (§9 round two).
403
+ return t.status === 200 && typeof b?.access_token === 'string' && b.refresh_token === undefined;
404
+ },
405
+ };
406
+ for (const type of snapshot.resourceTypes) {
407
+ const probe = witness[type];
408
+ if (!probe) {
409
+ violations.push(`resource type '${type}' has no reachability witness — the claim is unverified`);
410
+ continue;
411
+ }
412
+ if (!(await probe()))
413
+ violations.push(`resource type '${type}' is not reachable through any served endpoint`);
414
+ }
415
+ }
416
+ finally {
417
+ rmSync(root, { recursive: true, force: true });
418
+ }
419
+ return {
420
+ ok: violations.length === 0,
421
+ endpointsChecked: snapshot.implementedEndpoints.length,
422
+ endpointsProbed: probed,
423
+ resourceTypesChecked: snapshot.resourceTypes.length,
424
+ violations,
425
+ };
426
+ }
@@ -0,0 +1,70 @@
1
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
2
+ import { GoogleOAuthBudget, type GoogleOAuthBudgetOptions } from './googleoauth-budget.js';
3
+ /**
4
+ * The injected real-Google boundary. `execute` issues ONE request and returns the parsed JSON body.
5
+ * A real client (a raw fetch wrapper) is structurally assignable; tests pass a fake.
6
+ */
7
+ export type GoogleOAuthExecute = (method: 'GET' | 'POST', path: string, init?: {
8
+ headers?: Record<string, string>;
9
+ body?: string;
10
+ }) => Promise<Record<string, any>>;
11
+ export type LiveGoogleOAuthOptions = {
12
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
13
+ fetchImpl?: typeof fetch;
14
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
15
+ budget?: GoogleOAuthBudget;
16
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
17
+ budgetOptions?: GoogleOAuthBudgetOptions;
18
+ };
19
+ /**
20
+ * A live executor against the real Google OAuth endpoints, holding the operator's OWN access token.
21
+ *
22
+ * THIS IS THE ONE PLACE this pack issues a live Google request, and therefore the one place the
23
+ * rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request
24
+ * goes out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says
25
+ * stop) and the response is fed back (`recordCall`) so a `Retry-After` / 429 becomes a PERSISTED
26
+ * cooldown that makes every later call fail fast WITHOUT touching Google. There is deliberately no
27
+ * option to disable the guard and no value of `budget` that yields an unguarded client — a
28
+ * duck-typed stand-in, a SUBCLASS overriding `checkBudget`, and a Proxy trapping it are all refused
29
+ * (`assertBudgetGuardIntact`). What this cannot stop is deliberate sabotage from inside the process
30
+ * (an injected clock, a throwaway ledger path); the kernel's header states that limit rather than
31
+ * pretending otherwise.
32
+ */
33
+ export declare function liveGoogleOAuthExecute(accessToken: string, opts?: LiveGoogleOAuthOptions): GoogleOAuthExecute;
34
+ /** Map a real `tokeninfo` response → the OAuth client SyncResource it names. */
35
+ export declare function mapTokenInfoClient(info: Record<string, any>): SyncResource;
36
+ /** Map a real `userinfo` response → the account (persona) SyncResource. */
37
+ export declare function mapUserInfoAccount(profile: Record<string, any>): SyncResource;
38
+ /** Map a real `tokeninfo` response → the (client, user) grant SyncResource it evidences. */
39
+ export declare function mapTokenInfoGrant(info: Record<string, any>): SyncResource;
40
+ /** PULL the operator's own identity + client into the twin's observed log (idempotent). */
41
+ export declare function pullGoogleOAuthIdentity(execute: GoogleOAuthExecute, root?: string, occurredAt?: string): Promise<number>;
42
+ export declare function syncGoogleOAuthFromReal(execute: GoogleOAuthExecute, opts?: {
43
+ root?: string;
44
+ occurredAt?: string;
45
+ }): Promise<{
46
+ observed: number;
47
+ deltasAppended: number;
48
+ }>;
49
+ /** The pack's executor over the kernel's. */
50
+ export declare function googleOAuthExecuteOver(execute: RemoteExecute): GoogleOAuthExecute;
51
+ /** The refresh adapter: read the account this credential names (its identity and granted scopes). */
52
+ export declare function syncGoogleOAuthFromRemote(execute: RemoteExecute, opts?: {
53
+ root?: string;
54
+ origin?: string;
55
+ occurredAt?: string;
56
+ }): Promise<{
57
+ observed: number;
58
+ deltasAppended: number;
59
+ }>;
60
+ /** The perform adapter — and the honest answer is that NOTHING here can cross. Google publishes no API
61
+ * that creates an OAuth client, seeds a user or grants a scope: those are Cloud Console and
62
+ * myaccount.google.com actions a person takes in a browser. So every entry settles with that reason,
63
+ * which is exactly what the v1 push reported by refusing to confirm anything. */
64
+ export declare function performGoogleOAuthAction(_execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome>;
65
+ /** How much local state has no upstream home — the count the v1 push reported, kept for the claim that
66
+ * states this vendor's bound honestly. */
67
+ export declare function unpushableGoogleOAuthEntries(root?: string): {
68
+ pushed: 0;
69
+ unpushable: number;
70
+ };