@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,16 @@
1
+ export { ACCOUNTS_ORIGIN, APIS_ORIGIN, discoveryDocument, handleGoogleOAuthTwinRequest, ISSUER, OAUTH2_ORIGIN, OIDC_ORIGIN, } from './googleoauth-twin.js';
2
+ export type { GoogleOAuthRequest, GoogleOAuthResponse } from './googleoauth-twin.js';
3
+ export { createGoogleOAuthConsentServer, createGoogleOAuthTwinFetch, createGoogleOAuthTwinServer, type GoogleOAuthTwinFetchOptions } from './googleoauth-server.js';
4
+ export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, defaultRedirectUris, RESOURCE_TYPES, redirectUriAllowed, } from './googleoauth-store.js';
5
+ export { atHash, buildJwks, buildLegacyPemCerts, decodeJwt, ensureKeypair, pkceS256, setKeypairGenerator, signJwt, verifyJwtWithJwks, } from './googleoauth-jwt.js';
6
+ export type { Jwk, Jwks } from './googleoauth-jwt.js';
7
+ export { describeScope, describeScopes, formatScopeParam, isGranularlyDeclinable, OIDC_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from './googleoauth-scopes.js';
8
+ export type { ScopeInfo } from './googleoauth-scopes.js';
9
+ export { liveGoogleOAuthExecute, mapTokenInfoClient, mapTokenInfoGrant, mapUserInfoAccount, pullGoogleOAuthIdentity, unpushableGoogleOAuthEntries, syncGoogleOAuthFromReal, } from './googleoauth-connector.js';
10
+ export type { GoogleOAuthExecute, LiveGoogleOAuthOptions } from './googleoauth-connector.js';
11
+ export { GOOGLEOAUTH_BUDGET_CEILING, GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S, GOOGLEOAUTH_BUDGET_WINDOW_MS, GOOGLEOAUTH_CALL_WEIGHTS, GOOGLEOAUTH_RATE_BUDGET, GoogleOAuthBudget, GoogleOAuthBudgetError, googleOAuthBudgetPath, googleOAuthCallWeight, } from './googleoauth-budget.js';
12
+ export type { GoogleOAuthBudgetErrorKind, GoogleOAuthBudgetOptions, GoogleOAuthBudgetReservation, GoogleOAuthBudgetSnapshot, } from './googleoauth-budget.js';
13
+ export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, googleOAuthConsentState, } from './googleoauth-consent-ui.js';
14
+ export type { ConsentAccount, ConsentScopeRow, ConsentView } from './googleoauth-consent-ui.js';
15
+ import { type TwinPack } from '@volter/world-core';
16
+ export declare const pack: TwinPack;
@@ -0,0 +1,102 @@
1
+ // @volter/twin-googleoauth — the Google OAuth 2.0 / OpenID Connect twin (one vendor, one package),
2
+ // built on the shared @volter/world-core kernel.
3
+ //
4
+ // The catalog's first BROWSER-FACING pack: it serves the vendor's own consent screen as HTML at
5
+ // accounts.google.com's real path, completes the full authorization-code round trip (consent → 302
6
+ // with code+state → redemption at the token endpoint → refresh), and mints REAL RS256 id_tokens
7
+ // verifiable against the JWKS it serves. Conformance tooling lives in @volter/world-tooling (a dev
8
+ // dependency) — not shipped in the runtime API.
9
+ export { ACCOUNTS_ORIGIN, APIS_ORIGIN, discoveryDocument, handleGoogleOAuthTwinRequest, ISSUER, OAUTH2_ORIGIN, OIDC_ORIGIN, } from "./googleoauth-twin.js";
10
+ export { createGoogleOAuthConsentServer, createGoogleOAuthTwinFetch, createGoogleOAuthTwinServer } from "./googleoauth-server.js";
11
+ export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, defaultRedirectUris, RESOURCE_TYPES, redirectUriAllowed, } from "./googleoauth-store.js";
12
+ export { atHash, buildJwks, buildLegacyPemCerts, decodeJwt, ensureKeypair, pkceS256, setKeypairGenerator, signJwt, verifyJwtWithJwks, } from "./googleoauth-jwt.js";
13
+ export { describeScope, describeScopes, formatScopeParam, isGranularlyDeclinable, OIDC_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from "./googleoauth-scopes.js";
14
+ export { liveGoogleOAuthExecute, mapTokenInfoClient, mapTokenInfoGrant, mapUserInfoAccount, pullGoogleOAuthIdentity, unpushableGoogleOAuthEntries, syncGoogleOAuthFromReal, } from "./googleoauth-connector.js";
15
+ // The client-side rate budget — the fail-closed backstop `liveGoogleOAuthExecute` routes every live
16
+ // request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
17
+ // here is this vendor's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
18
+ // bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
19
+ // `GoogleOAuthBudgetError` by type; there is deliberately no export that disables the guard.
20
+ export { GOOGLEOAUTH_BUDGET_CEILING, GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S, GOOGLEOAUTH_BUDGET_WINDOW_MS, GOOGLEOAUTH_CALL_WEIGHTS, GOOGLEOAUTH_RATE_BUDGET, GoogleOAuthBudget, GoogleOAuthBudgetError, googleOAuthBudgetPath, googleOAuthCallWeight, } from "./googleoauth-budget.js";
21
+ export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, googleOAuthConsentState, } from "./googleoauth-consent-ui.js";
22
+ // Registry descriptor: the pack self-describes so tooling can discover it.
23
+ import { registerPack } from '@volter/world-core';
24
+ import { GOOGLEOAUTH_RATE_BUDGET as RATE_BUDGET } from "./googleoauth-budget.js";
25
+ import { performGoogleOAuthAction, syncGoogleOAuthFromRemote } from "./googleoauth-connector.js";
26
+ export const pack = {
27
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
28
+ // state system. Moved 2026-09-08. NOTHING here can cross: Google publishes no API that creates an OAuth
29
+ // client, seeds a user or grants a scope — those are Cloud Console and myaccount.google.com actions a
30
+ // person takes in a browser — so `perform` settles every entry with that reason and only `refresh` reads.
31
+ protocol: '2',
32
+ refresh: { every: '30m', onDemand: { atMost: '60s' } },
33
+ stateSystem: { perform: performGoogleOAuthAction, refresh: syncGoogleOAuthFromRemote },
34
+ // the round trip registers an OAuth client through the twin's own door, because the vendor has no API for
35
+ // it at all — which is the same reason `perform` never crosses
36
+ roundTrip: { method: 'POST', path: '/_twin/clients', body: { client_id: 'round-trip.apps.googleusercontent.com', name: 'Round Trip', redirect_uris: ['https://round.trip.test/callback'] } },
37
+ parityOrigin: 'http://twin',
38
+ // shapeParity is NOT held, for a structural reason rather than a divergence: the refresh reads the ACCOUNT
39
+ // this credential names (its identity and granted scopes) — there is no listing of OAuth clients to
40
+ // observe back, because Google publishes none. Written and observed are different subjects by design.
41
+ vendor: 'googleoauth',
42
+ // The SAME object googleoauth-budget.ts declares at module load — one source of truth, so
43
+ // registering the pack and importing the connector can never arm two different ceilings.
44
+ rateBudget: RATE_BUDGET,
45
+ transport: 'rest',
46
+ archetype: 'crud',
47
+ bin: 'world-googleoauth',
48
+ resources: ['oauth_client', 'account', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant'],
49
+ specSource: 'accounts.google.com/.well-known/openid-configuration (the vendor\'s own OIDC discovery document) '
50
+ + '+ developers.google.com/identity/protocols/oauth2/{web-server,native-app,openid-connect,service-account}',
51
+ description: 'Google OAuth 2.0 / OIDC twin — the real consent screen as HTML, the full authorization-code '
52
+ + 'round trip (PKCE S256, state, granular consent, refresh), real RS256 id_tokens and a real JWKS.',
53
+ // Adoption, all in the pack's one home (descriptor-first back-migration, adding-a-twin.md §3, 2026-08-31;
54
+ // `google-auth-library` and the bare `google`/`googleoauth`/`googleoidc` stems moved off the
55
+ // central maps unchanged).
56
+ //
57
+ // `google-auth-library` is the class every Google client builds its auth on (googleapis,
58
+ // @googleapis/*, @google-cloud/*), and it is the package an app adds when it does three-legged
59
+ // OAuth itself. `openid-client` is deliberately NOT claimed: it is a third-party generic OIDC
60
+ // library (panva), not a Google client, and claiming it would say any OIDC provider is this twin.
61
+ //
62
+ // GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI stem to `google` and are the
63
+ // OAuth-app credential shape — the exact vars the Cal.com blind-adoption run dropped nine of
64
+ // before _CLIENT_ID/_CLIENT_SECRET became strict suffixes (covers.ts's ENV_SUFFIX_STRICT). NB the
65
+ // `google-auth-library` package does NOT itself read these (it reads GOOGLE_APPLICATION_
66
+ // CREDENTIALS); apps set them and pass them to the constructor, which is exactly why the ENV
67
+ // signal and the SDK signal are separate and both worth having. The rest are the credential-var
68
+ // stems real apps use for sign-in-with-Google.
69
+ //
70
+ // NOTE: this pack's injector rule stays in the hand VENDOR_HOSTS table, and that is a ROUTING
71
+ // constraint, not an omission. It shares oauth2.googleapis.com and www.googleapis.com with the
72
+ // pack-less `googleauth` plumbing key, and it is declared BEFORE `googleauth` on purpose:
73
+ // `resolveTwin` returns the FIRST matching vendor in insertion order, so in a world where BOTH
74
+ // are configured the token endpoint goes to the pack (whose endpoint is a strict SUPERSET —
75
+ // authorization_code and refresh_token, which googleauth has no state for, PLUS the jwt-bearer
76
+ // service-account exchange, with a REAL RS256 id_token instead of gemini's `alg: none` stub).
77
+ // Descriptor-declared hosts are appended AFTER the whole hand table, so moving this entry alone
78
+ // would flip that precedence. It can move once `googleauth` has a home of its own.
79
+ adoption: {
80
+ // Google's official auth libraries - the Python counterparts of `google-auth-library`: token
81
+ // minting/refresh against oauth2.googleapis.com and the installed-app OAuth flow.
82
+ // `google-api-python-client` is deliberately UNCLAIMED: it is the generic multi-service Google
83
+ // API client (Drive, Sheets, YouTube, ...) whose calls go to whichever API the caller names,
84
+ // not to this pack's OAuth endpoints - claiming it here would map every Google-anything repo
85
+ // onto the OAuth twin.
86
+ pypi: ['google-auth', 'google-auth-oauthlib'],
87
+ sdks: ['google-auth-library', '@googleapis/oauth2'],
88
+ envStems: [
89
+ 'GOOGLE', 'GOOGLEOAUTH', 'GOOGLEOIDC',
90
+ 'AUTHGOOGLE', 'AUTHGOOGLEOAUTH2', 'BACKENDGOOGLE', 'GOOGLEIOS',
91
+ 'GOOGLELOGIN', 'GOOGLEWEB', 'NEXTPRIVATEGOOGLE', 'SSOGOOGLEOAUTH2',
92
+ // appsmith's Google OAuth app: APPSMITH_OAUTH2_GOOGLE_CLIENT_ID/_SECRET — the same
93
+ // app-prefixed shape as AUTHGOOGLEOAUTH2 / SSOGOOGLEOAUTH2 (ladder classification 2026-09-02).
94
+ 'APPSMITHOAUTH2GOOGLE',
95
+ ],
96
+ },
97
+ // A browser is REDIRECTED to accounts.google.com — there is no single API path prefix to proxy,
98
+ // so the loader host is the consent host and the prefix is the OAuth path root.
99
+ browserRouting: { apiPathPrefix: '/o/oauth2/', loaderHost: 'https://accounts.google.com' },
100
+ };
101
+ // registered at import: the kernel learns the pack's state system (protocol 2)
102
+ registerPack(pack);
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@volter/twin-googleoauth",
3
+ "version": "0.1.0",
4
+ "description": "Local Google OAuth 2.0 / OpenID Connect twin — the real consent screen, the real authorization-code round trip, and real RS256 id_tokens an unmodified OAuth client library completes a full flow against. Built on @volter/world-core.",
5
+ "keywords": [
6
+ "twin",
7
+ "local",
8
+ "mock",
9
+ "mirror",
10
+ "simulator",
11
+ "fixtures",
12
+ "testing",
13
+ "oauth",
14
+ "oauth2",
15
+ "openid-connect",
16
+ "oidc",
17
+ "google",
18
+ "consent",
19
+ "sign-in-with-google"
20
+ ],
21
+ "author": "Volter (https://github.com/volter-ai)",
22
+ "license": "Apache-2.0",
23
+ "files": [
24
+ "src",
25
+ "client",
26
+ "README.md",
27
+ "LICENSE",
28
+ "!**/*.test.ts",
29
+ "!**/*.test.tsx",
30
+ "dist"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/volter-ai/twin.git",
35
+ "directory": "packages/twin/googleoauth"
36
+ },
37
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/googleoauth#readme",
38
+ "type": "module",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/src/index.d.ts",
42
+ "default": "./dist/src/index.js"
43
+ }
44
+ },
45
+ "bin": {
46
+ "world-googleoauth": "dist/src/cli.js"
47
+ },
48
+ "scripts": {
49
+ "test": "bun test src/*.test.ts",
50
+ "typecheck": "tsc --noEmit",
51
+ "build": "node ../../../scripts/publish/build.mjs",
52
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
53
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
54
+ },
55
+ "dependencies": {
56
+ "react": "^19.2.7",
57
+ "react-dom": "^19.2.7"
58
+ },
59
+ "peerDependencies": {
60
+ "@volter/world-core": "2.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@volter/world-core": "2.0.0",
64
+ "@volter/world-tooling": "0.1.0",
65
+ "google-auth-library": "^9.15.1",
66
+ "@types/bun": "^1.2.20",
67
+ "@types/node": "^24.0.0",
68
+ "@types/react": "^19.2.17",
69
+ "@types/react-dom": "^19.2.3",
70
+ "typescript": "^5.9.0"
71
+ },
72
+ "engines": {
73
+ "node": ">=22.3"
74
+ }
75
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-googleoauth CLI: serve the Google OAuth 2.0 / OIDC twin, or run conformance.
4
+ //
5
+ // `serve` and `mirror` start the SAME server, because the consent screen is served by the twin at
6
+ // the vendor's own path — there is no second renderer to start. `mirror` additionally prints a
7
+ // ready-to-open authorization URL, which is the thing an operator actually wants when they ask to
8
+ // "see the UI".
9
+ import { hasFlag, optionValue } from '@volter/world-core/args';
10
+ import { createGoogleOAuthTwinServer } from './googleoauth-server.ts';
11
+ import { DEFAULT_CLIENT_ID, defaultRedirectUris } from './googleoauth-store.ts';
12
+
13
+ const [cmd, ...rest] = process.argv.slice(2);
14
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
15
+ const root = optionValue(rest, '--root') || undefined;
16
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
17
+
18
+ if (cmd === 'serve' || cmd === 'mirror') {
19
+ const s = await createGoogleOAuthTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
20
+ const origin = `http://127.0.0.1:${s.port}`;
21
+ process.stdout.write(`googleoauth twin (OAuth 2.0 / OIDC)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
22
+ if (cmd === 'mirror') {
23
+ const url = new URL(`${origin}/o/oauth2/v2/auth`);
24
+ url.searchParams.set('client_id', DEFAULT_CLIENT_ID);
25
+ url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]!);
26
+ url.searchParams.set('response_type', 'code');
27
+ url.searchParams.set('scope', 'openid email profile https://www.googleapis.com/auth/calendar.readonly');
28
+ url.searchParams.set('state', 'twin-demo-state');
29
+ url.searchParams.set('access_type', 'offline');
30
+ process.stdout.write(`consent screen: ${url.toString()}\n`);
31
+ }
32
+ await keepProcessAlive();
33
+ } else if (cmd === 'conformance') {
34
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
35
+ const { checkGoogleOAuthConformance } = await import('./googleoauth-conformance.ts');
36
+ const report = await checkGoogleOAuthConformance({ ...(root ? { root } : {}) });
37
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
38
+ if (!report.ok) process.exitCode = 1;
39
+ } else {
40
+ process.stdout.write('Usage: world-googleoauth serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
41
+ }
@@ -0,0 +1,150 @@
1
+ // Google's OAuth ERROR-PAGE mechanism, reproduced.
2
+ //
3
+ // THE FACT THIS FILE EXISTS FOR: when Google's authorization endpoint rejects a request for a
4
+ // configuration or parameter reason, it does NOT answer with an HTML body and it does NOT redirect
5
+ // to the caller's `redirect_uri`. It 302s to ITS OWN error page:
6
+ //
7
+ // https://accounts.google.com/signin/oauth/error
8
+ // ?authError=<base64url protobuf>&flowName=GeneralOAuthFlow&client_id=<id>
9
+ //
10
+ // and that page renders "Access blocked: …" plus the "Error <status>: <code>" line every integrator
11
+ // ends up searching for. The `authError` payload is a base64url-encoded protobuf with five fields:
12
+ //
13
+ // 1 (string) the OAuth error code e.g. "redirect_uri_mismatch"
14
+ // 2 (string) the human message e.g. "The OAuth client was not found."
15
+ // 3 (string) a documentation URL
16
+ // 4 (varint) the HTTP status the page names e.g. 401
17
+ // 5 (string) the echoed parameter e.g. "redirect_uri=https://evil.test/steal"
18
+ //
19
+ // Reproducing the REDIRECT (rather than serving HTML inline) is what makes the twin's browser
20
+ // behaviour match: the address bar changes to accounts.google.com/signin/oauth/error, which is
21
+ // exactly what a developer sees and screenshots when they file a bug.
22
+ //
23
+ // Grounded by a read-only live capture of the real endpoint on 2026-08-20 (the field numbering was
24
+ // decoded from real `authError` payloads); recorded in spec-sources.json.
25
+ //
26
+ // UNCONFIRMED, and therefore not asserted anywhere: the HTTP STATUS the rendered error page itself
27
+ // is served with. The status in field 4 is the one the page DISPLAYS. The twin serves the page as a
28
+ // normal 200 document and puts the status in the text, which is what the capture shows the page
29
+ // saying; `googleoauth.errors.error_page_http_status` is the filed todo.
30
+
31
+ export const ERROR_PAGE_PATH = '/signin/oauth/error';
32
+ /** Google puts this on every OAuth error-page URL; a real capture always carries it. */
33
+ export const FLOW_NAME = 'GeneralOAuthFlow';
34
+
35
+ export type AuthError = {
36
+ /** The OAuth error code (protobuf field 1). */
37
+ code: string;
38
+ /** The human-readable message (field 2). */
39
+ message: string;
40
+ /** The documentation URL Google links (field 3). */
41
+ docUrl?: string;
42
+ /** The HTTP status the page NAMES in its "Error <status>: <code>" line (field 4). */
43
+ status: number;
44
+ /** The offending parameter, echoed back (field 5). */
45
+ param?: string;
46
+ };
47
+
48
+ function varint(n: number): number[] {
49
+ const out: number[] = [];
50
+ let v = n;
51
+ while (v > 0x7f) {
52
+ out.push((v & 0x7f) | 0x80);
53
+ v >>>= 7;
54
+ }
55
+ out.push(v);
56
+ return out;
57
+ }
58
+
59
+ function stringField(field: number, value: string): number[] {
60
+ const bytes = Array.from(Buffer.from(value, 'utf8'));
61
+ return [(field << 3) | 2, ...varint(bytes.length), ...bytes];
62
+ }
63
+
64
+ /** Encode an AuthError as the base64url protobuf Google puts in `?authError=`. */
65
+ export function encodeAuthError(e: AuthError): string {
66
+ const out: number[] = [
67
+ ...stringField(1, e.code),
68
+ ...stringField(2, e.message),
69
+ ...(e.docUrl ? stringField(3, e.docUrl) : []),
70
+ (4 << 3) | 0,
71
+ ...varint(e.status),
72
+ ...(e.param ? stringField(5, e.param) : []),
73
+ ];
74
+ return Buffer.from(Uint8Array.from(out)).toString('base64url');
75
+ }
76
+
77
+ /** Decode a `?authError=` payload back into its fields. Returns null on anything unparseable. */
78
+ export function decodeAuthError(encoded: string): AuthError | null {
79
+ let buf: Buffer;
80
+ try {
81
+ buf = Buffer.from(encoded, 'base64url');
82
+ } catch {
83
+ return null;
84
+ }
85
+ const out: Partial<AuthError> = {};
86
+ let i = 0;
87
+ const readVarint = (): number => {
88
+ let result = 0;
89
+ let shift = 0;
90
+ while (i < buf.length) {
91
+ const byte = buf[i++]!;
92
+ result |= (byte & 0x7f) << shift;
93
+ if ((byte & 0x80) === 0) return result >>> 0;
94
+ shift += 7;
95
+ if (shift > 28) return -1;
96
+ }
97
+ return -1;
98
+ };
99
+ while (i < buf.length) {
100
+ const tag = readVarint();
101
+ if (tag < 0) return null;
102
+ const field = tag >>> 3;
103
+ const wire = tag & 7;
104
+ if (wire === 0) {
105
+ const value = readVarint();
106
+ if (value < 0) return null;
107
+ if (field === 4) out.status = value;
108
+ } else if (wire === 2) {
109
+ const len = readVarint();
110
+ if (len < 0 || i + len > buf.length) return null;
111
+ const value = buf.subarray(i, i + len).toString('utf8');
112
+ i += len;
113
+ if (field === 1) out.code = value;
114
+ else if (field === 2) out.message = value;
115
+ else if (field === 3) out.docUrl = value;
116
+ else if (field === 5) out.param = value;
117
+ } else {
118
+ return null; // a wire type Google's payload never uses
119
+ }
120
+ }
121
+ if (!out.code || out.message === undefined || out.status === undefined) return null;
122
+ return out as AuthError;
123
+ }
124
+
125
+ /** The full error-page URL an authorization request is bounced to. */
126
+ export function authErrorRedirect(origin: string, e: AuthError, clientId?: string): string {
127
+ const url = new URL(`${origin}${ERROR_PAGE_PATH}`);
128
+ url.searchParams.set('authError', encodeAuthError(e));
129
+ url.searchParams.set('flowName', FLOW_NAME);
130
+ if (clientId) url.searchParams.set('client_id', clientId);
131
+ return url.toString();
132
+ }
133
+
134
+ /** The two headline strings Google's error pages use, chosen by error code (captured verbatim). */
135
+ export function errorHeadline(code: string): string {
136
+ // `redirect_uri_mismatch` is an app-configuration failure, and Google words it as one; every
137
+ // other code lands on the generic authorization-error headline.
138
+ return code === 'redirect_uri_mismatch'
139
+ ? "Access blocked: This app's request is invalid"
140
+ : 'Access blocked: Authorization Error';
141
+ }
142
+
143
+ /** Google's documentation URLs, per error code (captured from real `authError` payloads). */
144
+ export const ERROR_DOC_URLS: Record<string, string> = {
145
+ redirect_uri_mismatch: 'https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation',
146
+ invalid_client: 'https://developers.google.com/identity/protocols/oauth2',
147
+ invalid_request: 'https://developers.google.com/identity/protocols/oauth2/web-server',
148
+ invalid_scope: 'https://developers.google.com/identity/protocols/oauth2/scopes',
149
+ unsupported_response_type: 'https://developers.google.com/identity/protocols/oauth2/web-server',
150
+ };
@@ -0,0 +1,147 @@
1
+ // Google OAuth's CLIENT-SIDE RATE BUDGET — this pack's DECLARATION (the numbers) plus the thin
2
+ // typed bindings `liveGoogleOAuthExecute` uses. The MECHANISM — the durable token-keyed ledger, the
3
+ // rolling window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt
4
+ // ledger — lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`). Read that
5
+ // module's header for the full rationale AND for the honest list of what the guard does not
6
+ // guarantee (an injected clock or ledger path still defeats it — it guards carelessness, not
7
+ // malice).
8
+ //
9
+ // ── HOW THE CEILING WAS CHOSEN ──────────────────────────────────────────────────────────────
10
+ // GOOGLE PUBLISHES NO SCALAR PER-MINUTE RATE LIMIT for its OAuth 2.0 endpoints. That is the
11
+ // honest finding, and it is the reason this declaration is NOT more permissive than the kernel's
12
+ // undeclared fallback. What Google DOES publish about these endpoints is quota of a different
13
+ // SHAPE entirely — per-user/per-client TOKEN COUNTS, not a request rate:
14
+ // • a Google Account can hold at most 100 refresh tokens per OAuth client; minting the 101st
15
+ // silently invalidates the oldest (developers.google.com/identity/protocols/oauth2, "Refresh
16
+ // token expiration");
17
+ // • a testing-mode app's refresh tokens expire after 7 days;
18
+ // • a client has a cap on unexpired refresh tokens across all users.
19
+ // None of those is a per-minute rate, so none of them can anchor a rolling-window ceiling — and
20
+ // dressing one up as one would be exactly the "never a guess presented as a vendor fact" failure
21
+ // ADDING_A_TWIN.md warns about. The ceiling therefore sits AT the kernel fallback's burst (30 calls
22
+ // a minute at the default weight), which is also why this pack needs no `VENDOR_BURST_ANCHOR`
23
+ // entry in scripts/rate-budget-isolation.test.ts.
24
+ //
25
+ // ── HOW THE WEIGHTS WERE CHOSEN (a judgement call, stated as one) ────────────────────────────
26
+ // The connector only ever READS (there is no OAuth write API — see the connector header), so every
27
+ // call is a read and every read costs the same 2. Exactly ONE call is priced up:
28
+ // • `POST /revoke` costs 10. Revocation is the one destructive thing this surface can do to a
29
+ // real account, and it takes the WHOLE grant with it (every access and refresh token AND the
30
+ // grant record for that client+user). A loop that revokes is a loop that logs a human out of an
31
+ // app, which is a human blast radius rather than a throttle.
32
+ // There is deliberately NO second rule. An earlier version carried `^GET /tokeninfo` at the DEFAULT
33
+ // weight "so the rule exists to be found and re-priced" — but a rule whose weight equals the default
34
+ // changes nothing and CANNOT BE PINNED: delete it and every assertion about it still passes, because
35
+ // the fallback returns the same number (§9 round two). A rule no test can lose is not a rule; if
36
+ // tokeninfo ever needs a different price, the rule arrives WITH the number that makes it testable.
37
+ import {
38
+ declareRateBudget,
39
+ rateBudgetPath,
40
+ rateBudgetWeight,
41
+ RateBudget,
42
+ type RateBudgetDeclaration,
43
+ type RateBudgetOptions,
44
+ type RateBudgetReservation,
45
+ type RateBudgetSnapshot,
46
+ } from '@volter/world-core';
47
+
48
+ const VENDOR = 'googleoauth';
49
+
50
+ /** Rolling window, in ms. Spend older than this is pruned. */
51
+ export const GOOGLEOAUTH_BUDGET_WINDOW_MS = 60_000;
52
+
53
+ /**
54
+ * Weighted units allowed inside one window. 60/60s = 30 calls a minute at the default weight —
55
+ * EXACTLY the kernel fallback's burst, because Google publishes no rate this could be measured
56
+ * against. See the header.
57
+ */
58
+ export const GOOGLEOAUTH_BUDGET_CEILING = 60;
59
+
60
+ /** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
61
+ export const GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S = 300;
62
+
63
+ /** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is judged vs. documented. */
64
+ export const GOOGLEOAUTH_CALL_WEIGHTS = {
65
+ /** `POST /revoke` — destroys a whole grant on a REAL account. The only destructive call here. */
66
+ revoke: 10,
67
+ /** Everything else: discovery, certs, userinfo, tokeninfo. */
68
+ other: 2,
69
+ } as const;
70
+
71
+ /** THE PACK'S DECLARATION — pure data, the only Google-specific thing in the whole budget. */
72
+ export const GOOGLEOAUTH_RATE_BUDGET: RateBudgetDeclaration = {
73
+ windowMs: GOOGLEOAUTH_BUDGET_WINDOW_MS,
74
+ ceiling: GOOGLEOAUTH_BUDGET_CEILING,
75
+ defaultWeight: GOOGLEOAUTH_CALL_WEIGHTS.other,
76
+ maxRetryAfterSeconds: GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S,
77
+ rules: [{ match: '^POST /revoke$', weight: GOOGLEOAUTH_CALL_WEIGHTS.revoke }],
78
+ reason:
79
+ 'Google publishes NO scalar per-minute rate limit for its OAuth 2.0 / OIDC endpoints '
80
+ + '(accounts.google.com, oauth2.googleapis.com, www.googleapis.com/oauth2/*). What it does '
81
+ + 'publish for this surface is quota of a different SHAPE — per-user/per-client TOKEN COUNTS, '
82
+ + 'not a request rate: at most 100 refresh tokens per Google Account per OAuth client (the '
83
+ + '101st silently invalidates the oldest), a 7-day refresh-token expiry for apps still in '
84
+ + 'testing status, and a per-client cap on unexpired refresh tokens '
85
+ + '(developers.google.com/identity/protocols/oauth2, "Refresh token expiration"). A token-count '
86
+ + 'cap cannot anchor a rolling-window rate, so this declaration does NOT claim it does and does '
87
+ + "NOT out-burst the kernel's undeclared fallback: 60 weighted units / 60s at defaultWeight 2 is "
88
+ + '30 calls a minute, exactly the fallback. POST /revoke costs 10 (it destroys the WHOLE grant '
89
+ + 'on a real account — every access and refresh token for that client+user — which is a human '
90
+ + 'blast radius, not a throttle). It is the ONLY rule: a rule at the default weight changes no '
91
+ + 'price and cannot be pinned by any test, so there is none. That price is a judgement call, not '
92
+ + 'a published cost. The window bounds the 60s AVERAGE and does not pace; the 429 / Retry-After '
93
+ + 'cooldown is the backstop for a sub-second burst.',
94
+ };
95
+
96
+ // Declared at module load, so merely importing this module (which `googleoauth-connector.ts` does)
97
+ // is enough to arm the real ceiling.
98
+ declareRateBudget(VENDOR, GOOGLEOAUTH_RATE_BUDGET);
99
+
100
+ /**
101
+ * Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
102
+ * (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
103
+ * evaded by input variation: `fetch` upper-cases a known method before sending, so
104
+ * `execute('post', '/revoke')` really does issue the destructive call and must be priced as one.
105
+ */
106
+ export function googleOAuthCallWeight(method: string, path: string): number {
107
+ const { bare, query } = splitQuery(path);
108
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
109
+ }
110
+
111
+ function splitQuery(path: string): { bare: string; query: Record<string, string> } {
112
+ const at = path.indexOf('?');
113
+ const query: Record<string, string> = {};
114
+ if (at !== -1) for (const [k, v] of new URLSearchParams(path.slice(at + 1))) query[k] = v;
115
+ const raw = at === -1 ? path : path.slice(0, at);
116
+ const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
117
+ return { bare, query };
118
+ }
119
+
120
+ /** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to opt
121
+ * into world-scoped accounting. */
122
+ export function googleOAuthBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
123
+ const o = typeof opts === 'string' ? { root: opts } : opts;
124
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
125
+ // excess-property check only catches object literals) must not redirect this pack's ledger.
126
+ return rateBudgetPath({ ...o, vendor: VENDOR });
127
+ }
128
+
129
+ /** Construction options. The vendor is fixed; everything else may only TIGHTEN. */
130
+ export type GoogleOAuthBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
131
+
132
+ /**
133
+ * This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not an
134
+ * alias, so `budget instanceof GoogleOAuthBudget` means "a budget that accounts against
135
+ * GOOGLEOAUTH's ledger under GOOGLEOAUTH's ceiling": another vendor's `RateBudget` (with its own,
136
+ * possibly larger, ceiling) is NOT assignable there.
137
+ */
138
+ export class GoogleOAuthBudget extends RateBudget {
139
+ constructor(opts: GoogleOAuthBudgetOptions = {}) {
140
+ super({ ...opts, vendor: VENDOR });
141
+ }
142
+ }
143
+
144
+ export { RateBudgetError as GoogleOAuthBudgetError } from '@volter/world-core';
145
+ export type { RateBudgetErrorKind as GoogleOAuthBudgetErrorKind } from '@volter/world-core';
146
+ export type GoogleOAuthBudgetReservation = RateBudgetReservation;
147
+ export type GoogleOAuthBudgetSnapshot = RateBudgetSnapshot;