@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,78 @@
1
+ import { type TwinResource } from '@volter/world-core';
2
+ export declare const SERVICE = "googleoauth";
3
+ export declare const RESOURCE_TYPES: readonly ["oauth_client", "account", "auth_request", "authorization_code", "access_token", "refresh_token", "grant"];
4
+ export type ResourceType = (typeof RESOURCE_TYPES)[number];
5
+ export type Row = TwinResource & Record<string, any>;
6
+ export declare function readAll(root: string | undefined): Row[];
7
+ export declare function readType(root: string | undefined, type: ResourceType): Row[];
8
+ export declare function readOne(root: string | undefined, type: ResourceType, id: string): Row | undefined;
9
+ /** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
10
+ export declare function nextRev(root: string | undefined, type: ResourceType, id: string): number;
11
+ export type WriteOpts = {
12
+ root?: string;
13
+ occurredAt?: string;
14
+ };
15
+ /** Seeding also needs the origin the twin was reached on, so the demo client's registered
16
+ * callbacks point back at THIS twin rather than at a baked-in port (runtime contract R7). */
17
+ export type SeedOpts = WriteOpts & {
18
+ origin?: string;
19
+ };
20
+ export declare function write(type: ResourceType, id: string, operation: string, fields: Record<string, unknown>, opts: WriteOpts): Promise<Row>;
21
+ /** `4/0A…` — Google's authorization codes. `subject` is the consent screen this code settles. */
22
+ export declare const mintAuthorizationCode: (root: string | undefined, instant: string, subject: string) => string;
23
+ /** `ya29.…` — Google's OAuth 2.0 access tokens. `subject` is the code or refresh token exchanged. */
24
+ export declare const mintAccessToken: (root: string | undefined, instant: string, subject: string) => string;
25
+ /** `1//0…` — Google's refresh tokens. `subject` is the code the grant was exchanged from. */
26
+ export declare const mintRefreshToken: (root: string | undefined, instant: string, subject: string) => string;
27
+ /**
28
+ * Twin-internal handle for a consent screen in flight (never leaves the twin's own pages) —
29
+ * `ar_` + 32 hex, the shape `randomUUID()` gave it, now a pure function of (request, stored state).
30
+ *
31
+ * An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
32
+ * id: pressing reload on a consent screen is the same screen, not a new one. Otherwise the id is
33
+ * `stableHex(auth_request:<world instant>:<rows already held>)` — two authorize requests at one
34
+ * instant differ by the count, and two identical worlds mint identical ids.
35
+ */
36
+ export declare function authRequestIdFor(root: string | undefined, occurredAt: string, fields: Record<string, unknown>): string;
37
+ export declare const DEFAULT_CLIENT_ID = "1044839207-twindemoapp0000000000000000000.apps.googleusercontent.com";
38
+ export declare const DEFAULT_CLIENT_SECRET = "GOCSPX-twin0000000000000000000000";
39
+ export type SeedAccount = {
40
+ sub: string;
41
+ email: string;
42
+ name: string;
43
+ givenName: string;
44
+ familyName: string;
45
+ picture: string;
46
+ emailVerified: boolean;
47
+ hd?: string;
48
+ };
49
+ /** Google's `sub` is a stable 21-digit numeric string; these are deterministic stand-ins. */
50
+ export declare const DEFAULT_ACCOUNTS: SeedAccount[];
51
+ /**
52
+ * The redirect URIs the seeded demo client registers.
53
+ *
54
+ * Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
55
+ * DERIVED from the origin the twin was actually reached on (`GoogleOAuthRequest.origin`, which
56
+ * googleoauth-server fills from the URL the request arrived at). A caller that declares no origin
57
+ * — an in-process call — gets the host-free out-of-band URN only, and registers whatever callbacks
58
+ * it wants through `POST /_twin/clients`.
59
+ */
60
+ export declare function defaultRedirectUris(origin?: string): string[];
61
+ /**
62
+ * Materialise the default client + accounts once per root. Idempotent by subject id: re-running it
63
+ * over a seeded root writes nothing new, and it NEVER overwrites an operator-registered client or
64
+ * account (an existing subject id is left exactly as it is).
65
+ */
66
+ export declare function ensureSeed(opts: SeedOpts): Promise<void>;
67
+ /** Every account the chooser can offer, oldest-seeded first (a stable screen order). */
68
+ export declare function listAccounts(root: string | undefined): Row[];
69
+ /**
70
+ * Is `candidate` an EXACT registered redirect URI for this client? Google matches redirect URIs
71
+ * exactly (scheme, host, port and path all compared literally; a trailing slash is a different
72
+ * URI), which is precisely why `redirect_uri_mismatch` is the single most common integration
73
+ * error. A twin that matched loosely would hide every one of those bugs.
74
+ *
75
+ * The one documented exception is the loopback-IP flow for installed apps: Google ignores the PORT
76
+ * for `http://127.0.0.1` / `http://[::1]` redirect URIs, because the app binds an ephemeral port.
77
+ */
78
+ export declare function redirectUriAllowed(client: Row, candidate: string): boolean;
@@ -0,0 +1,313 @@
1
+ // Google OAuth twin — STATE. Everything the twin knows lives in the shared kernel action log
2
+ // (D1): OAuth clients, the Google accounts a user can choose between, pending authorization
3
+ // requests, authorization codes, access/refresh tokens and the per-(client, user) grant record.
4
+ // There is no parallel store.
5
+ //
6
+ // ID MINTING (ADDING_A_TWIN.md §5): every id is either the vendor's own natural key (a client_id,
7
+ // an account `sub`) or MINTED HERE. The minted ones used to be ENTROPIC (`randomUUID`) — never a
8
+ // module counter and never a row count, the collision class three packs' §9 reviews each found
9
+ // independently. Every one of them is also SERVED: the `ar_` consent handle is rendered into the
10
+ // screen, the `4/0A` code rides the callback redirect, the `ya29.`/`1//0` tokens ride the /token
11
+ // reply. Runtime contract R9 (a served response is a pure function of (request, stored state) —
12
+ // no clock and no randomness in served content) therefore governs all of them, and entropy is
13
+ // exactly the defect it names, so they are ALL minted the resend way now: a stable hash of the
14
+ // world instant + the number of rows of that type the store already holds (soft-deleted rows
15
+ // counted, so a value can never recur) + the subject it is issued for.
16
+ //
17
+ // §5's count-mint hazard — a pulled vendor id sitting in a GAP above the row count, which local
18
+ // creates then walk into and clobber — cannot arise here: these types are twin-internal or
19
+ // twin-issued, no connector pulls one, and the count is a hash SEED rather than the id itself.
20
+ // What §5's entropy bought (two writes at one instant never colliding) the count buys instead:
21
+ // it advances on every mint, so `googleoauth-twin.test.ts`'s "two grants in the same millisecond
22
+ // mint DIFFERENT credentials" holds by construction rather than by luck.
23
+ //
24
+ // The `ar_` handle carries one extra rule the credentials do not: an authorize request whose
25
+ // parameters already name an UNSETTLED pending row reuses that row's id, so re-serving the same
26
+ // consent screen is byte-identical (and a reload does not pile up a second pending request).
27
+ //
28
+ // The one state shape that DOES revisit values is the mutable-in-place kind: a code being consumed,
29
+ // a token being revoked, a grant's scope set being re-granted with the same scopes. Those carry a
30
+ // per-subject `rev` ordinal (the upstash precedent) so a genuine write can never be mistaken
31
+ // for a replay.
32
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
33
+ export const SERVICE = 'googleoauth';
34
+ export const RESOURCE_TYPES = [
35
+ 'oauth_client',
36
+ 'account',
37
+ 'auth_request',
38
+ 'authorization_code',
39
+ 'access_token',
40
+ 'refresh_token',
41
+ 'grant',
42
+ ];
43
+ export function readAll(root) {
44
+ return projectResources(SERVICE, root);
45
+ }
46
+ export function readType(root, type) {
47
+ return readAll(root).filter((r) => r.type === type);
48
+ }
49
+ export function readOne(root, type, id) {
50
+ return readAll(root).find((r) => r.type === type && r.id === id);
51
+ }
52
+ /** The next `rev` for a subject that is being rewritten in place (survives deletes/tombstones). */
53
+ export function nextRev(root, type, id) {
54
+ const existing = readOne(root, type, id);
55
+ return (typeof existing?.rev === 'number' ? existing.rev : 0) + 1;
56
+ }
57
+ export async function write(type, id, operation, fields, opts) {
58
+ const { resource } = await applyTwinWrite(SERVICE, {
59
+ operation,
60
+ subjectType: type,
61
+ subjectId: id,
62
+ fields,
63
+ ...(opts.occurredAt ? { occurredAt: opts.occurredAt } : {}),
64
+ actor: { kind: 'agent' },
65
+ }, opts.root);
66
+ return resource;
67
+ }
68
+ // ── vendor-shaped credential minting: DETERMINISTIC, because it is SERVED (R9) ──────────────
69
+ // Google's opaque credentials have RECOGNISABLE prefixes and an integrator's code often keys on
70
+ // them (a `ya29.` access token, a `1//` refresh token, a `4/0A` auth code). The twin reproduces the
71
+ // prefix and the LENGTH — the shape is vendor surface, the bytes are not.
72
+ //
73
+ // The bytes used to be `randomUUID()` entropy, and every one of them is SERVED (the code rides the
74
+ // callback redirect, the tokens ride the /token reply), so R9 governs them exactly as it governs
75
+ // the `ar_` handle below. They are now the resend pattern: 32 bytes of `stableHex` over
76
+ // (type, world instant, rows of that type held BEFORE the insert, the subject being issued for),
77
+ // base64url-ed and sliced to the vendor's length — the SAME construction as before, with the
78
+ // entropy source swapped for a pure hash, so the shape is byte-for-byte what it was.
79
+ //
80
+ // A twin's credentials are stand-ins, not secrets: determinism is the contract, and predictability
81
+ // from public state is not a threat model a twin has. UNIQUENESS is, and the seed carries it: the
82
+ // row COUNT advances on every mint, so two credentials of one type at one instant differ even when
83
+ // the subject is identical (`googleoauth-twin.test.ts` — "two grants in the same millisecond mint
84
+ // DIFFERENT credentials"), and the subject separates concurrent exchanges.
85
+ /** The seed every credential is minted from. `readType` is unfiltered (soft-deletes counted), so a
86
+ * count — and therefore a credential — can never recur for one (type, instant, subject). */
87
+ function credentialSeed(root, type, instant, subject) {
88
+ return `${type}:${instant}:${readType(root, type).length}:${subject}`;
89
+ }
90
+ /** base64url over 32 hashed bytes, sliced to the vendor's length (unchanged from the entropic
91
+ * version — only the byte source moved from `randomUUID` to `stableHex`). */
92
+ const opaque = (seed, bytes = 24) => Buffer.from(stableHex(seed, 64), 'hex')
93
+ .toString('base64')
94
+ .replace(/=/g, '')
95
+ .replace(/\+/g, '-')
96
+ .replace(/\//g, '_')
97
+ .slice(0, bytes);
98
+ /** `4/0A…` — Google's authorization codes. `subject` is the consent screen this code settles. */
99
+ export const mintAuthorizationCode = (root, instant, subject) => `4/0A${opaque(credentialSeed(root, 'authorization_code', instant, subject), 40)}`;
100
+ /** `ya29.…` — Google's OAuth 2.0 access tokens. `subject` is the code or refresh token exchanged. */
101
+ export const mintAccessToken = (root, instant, subject) => `ya29.${opaque(credentialSeed(root, 'access_token', instant, subject), 64)}`;
102
+ /** `1//0…` — Google's refresh tokens. `subject` is the code the grant was exchanged from. */
103
+ export const mintRefreshToken = (root, instant, subject) => `1//0${opaque(credentialSeed(root, 'refresh_token', instant, subject), 48)}`;
104
+ // ── the consent screen's handle: DETERMINISTIC, because it is SERVED (R9) ───────────────────
105
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
106
+ * (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
107
+ function stableHex(seed, n) {
108
+ let out = '';
109
+ for (let round = 0; out.length < n; round += 1) {
110
+ let h = 0x811c9dc5;
111
+ const s = `${round}:${seed}`;
112
+ for (let i = 0; i < s.length; i += 1) {
113
+ h ^= s.charCodeAt(i);
114
+ h = Math.imul(h, 0x01000193) >>> 0;
115
+ }
116
+ out += h.toString(16).padStart(8, '0');
117
+ }
118
+ return out.slice(0, n);
119
+ }
120
+ /** Every authorize parameter an `auth_request` row holds — its stable identity. Read off the
121
+ * fields being written OR off a projected row (the key set is the same on both). */
122
+ const AUTH_REQUEST_SIG_KEYS = [
123
+ 'clientId', 'redirectUri', 'scope', 'state', 'nonce', 'accessType', 'prompt', 'loginHint',
124
+ 'includeGrantedScopes', 'codeChallenge', 'codeChallengeMethod',
125
+ ];
126
+ function authRequestSignature(fields) {
127
+ return AUTH_REQUEST_SIG_KEYS.map((k) => `${k}=${String(fields[k] ?? '')}`).join('\u0001');
128
+ }
129
+ /**
130
+ * Twin-internal handle for a consent screen in flight (never leaves the twin's own pages) —
131
+ * `ar_` + 32 hex, the shape `randomUUID()` gave it, now a pure function of (request, stored state).
132
+ *
133
+ * An authorize request whose parameters ALREADY name an unsettled pending row reuses that row's
134
+ * id: pressing reload on a consent screen is the same screen, not a new one. Otherwise the id is
135
+ * `stableHex(auth_request:<world instant>:<rows already held>)` — two authorize requests at one
136
+ * instant differ by the count, and two identical worlds mint identical ids.
137
+ */
138
+ export function authRequestIdFor(root, occurredAt, fields) {
139
+ const prior = readType(root, 'auth_request');
140
+ const signature = authRequestSignature(fields);
141
+ const reusable = prior.find((r) => r.settled !== true && authRequestSignature(r) === signature);
142
+ if (reusable)
143
+ return String(reusable.id);
144
+ return `ar_${stableHex(`auth_request:${occurredAt}:${prior.length}`, 32)}`;
145
+ }
146
+ // ── seeded world ────────────────────────────────────────────────────────────────────────────
147
+ // A twin has to have SOMETHING to consent as. Google's own account chooser lists the Google
148
+ // accounts signed in to the browser; the twin's equivalent is a small set of deterministic
149
+ // personas seeded into kernel state on first use, and `POST /_twin/accounts` adds more. The
150
+ // default OAuth client mirrors the shape the Google Cloud console hands out (a `.apps.
151
+ // googleusercontent.com` client_id and a `GOCSPX-` secret).
152
+ export const DEFAULT_CLIENT_ID = '1044839207-twindemoapp0000000000000000000.apps.googleusercontent.com';
153
+ export const DEFAULT_CLIENT_SECRET = 'GOCSPX-twin0000000000000000000000';
154
+ /** Google's `sub` is a stable 21-digit numeric string; these are deterministic stand-ins. */
155
+ export const DEFAULT_ACCOUNTS = [
156
+ {
157
+ sub: '100000000000000000001',
158
+ email: 'ada.lovelace@gmail.com',
159
+ name: 'Ada Lovelace',
160
+ givenName: 'Ada',
161
+ familyName: 'Lovelace',
162
+ picture: 'https://lh3.googleusercontent.com/a/twin-ada',
163
+ emailVerified: true,
164
+ },
165
+ {
166
+ sub: '100000000000000000002',
167
+ email: 'grace.hopper@navy.example',
168
+ name: 'Grace Hopper',
169
+ givenName: 'Grace',
170
+ familyName: 'Hopper',
171
+ picture: 'https://lh3.googleusercontent.com/a/twin-grace',
172
+ emailVerified: true,
173
+ hd: 'navy.example',
174
+ },
175
+ ];
176
+ /** NextAuth's callback path — the one every Google integration guide walks a reader through. */
177
+ const CALLBACK_PATH = '/api/auth/callback/google';
178
+ /** Google's out-of-band redirect for installed apps: an URN, so it carries no host at all. */
179
+ const OOB_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob';
180
+ /**
181
+ * Every spelling of the ORIGIN this twin was reached on that a browser could hand back.
182
+ *
183
+ * Google matches redirect URIs EXACTLY, and it treats `localhost` and the loopback IP as different
184
+ * hosts (see `redirectUriAllowed` below) — while a local app is reachable as BOTH on the same port.
185
+ * A demo registration that named only one of them would fail half the time for a reason the
186
+ * developer cannot see, so the seed registers both spellings of whatever origin it was reached on.
187
+ */
188
+ function originSpellings(origin) {
189
+ const base = (origin ?? '').trim().replace(/\/+$/, '');
190
+ if (!base)
191
+ return [];
192
+ let u;
193
+ try {
194
+ u = new URL(base);
195
+ }
196
+ catch {
197
+ return [base];
198
+ }
199
+ if (u.protocol !== 'http:')
200
+ return [base];
201
+ const port = u.port ? `:${u.port}` : '';
202
+ if (u.hostname === 'localhost')
203
+ return [base, `http://127.0.0.1${port}`];
204
+ if (u.hostname === '127.0.0.1')
205
+ return [base, `http://localhost${port}`];
206
+ return [base];
207
+ }
208
+ /**
209
+ * The redirect URIs the seeded demo client registers.
210
+ *
211
+ * Runtime contract R7: a twin NEVER bakes a port into served content or config. The callbacks are
212
+ * DERIVED from the origin the twin was actually reached on (`GoogleOAuthRequest.origin`, which
213
+ * googleoauth-server fills from the URL the request arrived at). A caller that declares no origin
214
+ * — an in-process call — gets the host-free out-of-band URN only, and registers whatever callbacks
215
+ * it wants through `POST /_twin/clients`.
216
+ */
217
+ export function defaultRedirectUris(origin) {
218
+ return [...originSpellings(origin).map((base) => `${base}${CALLBACK_PATH}`), OOB_REDIRECT];
219
+ }
220
+ /**
221
+ * Materialise the default client + accounts once per root. Idempotent by subject id: re-running it
222
+ * over a seeded root writes nothing new, and it NEVER overwrites an operator-registered client or
223
+ * account (an existing subject id is left exactly as it is).
224
+ */
225
+ export async function ensureSeed(opts) {
226
+ const existing = readAll(opts.root);
227
+ const has = (type, id) => existing.some((r) => r.type === type && r.id === id);
228
+ if (!has('oauth_client', DEFAULT_CLIENT_ID)) {
229
+ await write('oauth_client', DEFAULT_CLIENT_ID, 'oauth_client.create', {
230
+ name: 'Twin Demo App',
231
+ secret: DEFAULT_CLIENT_SECRET,
232
+ redirectUris: defaultRedirectUris(opts.origin),
233
+ clientType: 'web',
234
+ supportEmail: 'support@twin.example',
235
+ verified: false,
236
+ rev: 1,
237
+ }, opts);
238
+ }
239
+ for (const account of DEFAULT_ACCOUNTS) {
240
+ if (has('account', account.sub))
241
+ continue;
242
+ await write('account', account.sub, 'account.create', {
243
+ email: account.email,
244
+ emailVerified: account.emailVerified,
245
+ name: account.name,
246
+ givenName: account.givenName,
247
+ familyName: account.familyName,
248
+ picture: account.picture,
249
+ ...(account.hd ? { hd: account.hd } : {}),
250
+ rev: 1,
251
+ }, opts);
252
+ }
253
+ }
254
+ /** Every account the chooser can offer, oldest-seeded first (a stable screen order). */
255
+ export function listAccounts(root) {
256
+ return readType(root, 'account');
257
+ }
258
+ /**
259
+ * Is `candidate` an EXACT registered redirect URI for this client? Google matches redirect URIs
260
+ * exactly (scheme, host, port and path all compared literally; a trailing slash is a different
261
+ * URI), which is precisely why `redirect_uri_mismatch` is the single most common integration
262
+ * error. A twin that matched loosely would hide every one of those bugs.
263
+ *
264
+ * The one documented exception is the loopback-IP flow for installed apps: Google ignores the PORT
265
+ * for `http://127.0.0.1` / `http://[::1]` redirect URIs, because the app binds an ephemeral port.
266
+ */
267
+ export function redirectUriAllowed(client, candidate) {
268
+ const registered = Array.isArray(client.redirectUris) ? client.redirectUris : [];
269
+ // A FRAGMENT is refused outright, before any comparison. Google documents that a redirect URI may
270
+ // not contain one, and an exact-string match alone did not catch it: the loopback branch below
271
+ // compares protocol/host/path/search and never looked at `hash`, so
272
+ // `http://127.0.0.1:3000/cb#evil` was accepted against a registration of `http://127.0.0.1:3000/cb`
273
+ // (§9 round two). A fragment is attacker-controlled and never reaches the server, which is
274
+ // exactly why it is banned.
275
+ if (candidate.includes('#'))
276
+ return false;
277
+ // USERINFO (`http://user:pass@host/…`) likewise: Google forbids the subcomponent, and the
278
+ // loopback branch never compared it either.
279
+ if (/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(candidate))
280
+ return false;
281
+ if (registered.includes(candidate))
282
+ return true;
283
+ return registered.some((uri) => loopbackEquivalent(uri, candidate));
284
+ }
285
+ function loopbackEquivalent(registered, candidate) {
286
+ let a, b;
287
+ try {
288
+ a = new URL(registered);
289
+ b = new URL(candidate);
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ if (a.protocol !== 'http:' || b.protocol !== 'http:')
295
+ return false;
296
+ // The exception is documented for the loopback IP LITERALS only (`http://127.0.0.1:<port>` and
297
+ // `http://[::1]:<port>` — an installed app binds a random available port). `localhost` is NOT in
298
+ // that list: Google treats it as an ordinary host string that must match exactly, port included.
299
+ // `URL` normalises `[::1]` to the bracketed form in `hostname`, so both spellings land here.
300
+ const loopbackIp = (h) => h === '127.0.0.1' || h === '[::1]';
301
+ if (!loopbackIp(a.hostname) || !loopbackIp(b.hostname))
302
+ return false;
303
+ // The CANDIDATE's host must be the literal Google documents, not merely something WHATWG `URL`
304
+ // normalises to it. `URL` folds `0177.0.0.1`, `2130706433` and `127.1` all onto `127.0.0.1`, so
305
+ // reading `.hostname` alone accepted three spellings Google's exact-match rule does not
306
+ // (§9 round two). Compare the raw authority the caller actually sent.
307
+ const rawHost = /^https?:\/\/([^/?#]*)/i.exec(candidate)?.[1] ?? '';
308
+ const rawName = rawHost.replace(/:\d+$/, '');
309
+ if (rawName !== '127.0.0.1' && rawName !== '[::1]')
310
+ return false;
311
+ // Only the PORT is forgiven — the host and path still have to agree exactly.
312
+ return a.hostname === b.hostname && a.pathname === b.pathname && a.search === b.search;
313
+ }
@@ -0,0 +1,53 @@
1
+ export { RESOURCE_TYPES } from './googleoauth-store.js';
2
+ /** Google's real hosts, used when a caller does not tell the twin its own origin. */
3
+ export declare const ACCOUNTS_ORIGIN = "https://accounts.google.com";
4
+ export declare const OAUTH2_ORIGIN = "https://oauth2.googleapis.com";
5
+ export declare const APIS_ORIGIN = "https://www.googleapis.com";
6
+ export declare const OIDC_ORIGIN = "https://openidconnect.googleapis.com";
7
+ /** The `iss` Google puts in every id_token. (Google historically also used the bare
8
+ * `accounts.google.com`; verifiers accept both, and this twin issues the URL form.) */
9
+ export declare const ISSUER = "https://accounts.google.com";
10
+ export type GoogleOAuthRequest = {
11
+ method: string;
12
+ /** Path plus query string, e.g. `/o/oauth2/v2/auth?client_id=…`. */
13
+ path: string;
14
+ body?: string;
15
+ /** Lower-cased request headers (authorization / content-type). */
16
+ headers?: Record<string, string>;
17
+ occurredAt?: string;
18
+ root?: string;
19
+ readOnly?: boolean;
20
+ /**
21
+ * Where this twin is reached (`twinPublicBase`: origin plus any served-World mount path). The
22
+ * DISCOVERY document, the auth-error redirect and the consent page's own form action have to
23
+ * point back at the twin or a discovery-driven client (openid-client) would follow them out to
24
+ * the real Google. Absent — an in-process call — the
25
+ * document is rendered with Google's REAL endpoint URLs, which is the vendor-faithful answer.
26
+ */
27
+ origin?: string;
28
+ /** The bare origin the request arrived at, when it differs from `origin` (a served World mounts
29
+ * the twin under a path). The seeded demo app's callbacks derive from it; defaults to `origin`. */
30
+ callbackOrigin?: string;
31
+ };
32
+ export type GoogleOAuthResponse = {
33
+ status: number;
34
+ /** A JSON-serialisable object, or a STRING when the response is an HTML page. */
35
+ body: unknown;
36
+ headers?: Record<string, string>;
37
+ };
38
+ /**
39
+ * The OIDC discovery document. Every URL is rendered against `origin` when the caller supplied one,
40
+ * so a DISCOVERY-DRIVEN client (openid-client) that reads this document stays inside the twin
41
+ * instead of walking back out to the real Google — the single most important property of this
42
+ * endpoint for a sealed world.
43
+ */
44
+ export declare function discoveryDocument(origin?: string): Record<string, unknown>;
45
+ /** Every VENDOR endpoint this twin claims to serve. The conformance check drives one real request
46
+ * per entry and grades the OUTCOME, so an entry here is a promise with teeth. Twin-only routes
47
+ * (`/_twin/*`) are deliberately absent — they are scaffolding, not vendor surface. */
48
+ export declare function googleOAuthTwinSnapshot(): {
49
+ implementedEndpoints: string[];
50
+ resourceTypes: readonly string[];
51
+ grantTypes: string[];
52
+ };
53
+ export declare function handleGoogleOAuthTwinRequest(req: GoogleOAuthRequest): Promise<GoogleOAuthResponse>;