@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.
- package/README.md +219 -0
- package/client/googleoauth-consent.css +207 -0
- package/client/googleoauth-consent.tsx +286 -0
- package/dist/client/googleoauth-consent.bundle.js +237 -0
- package/dist/client/googleoauth-consent.css +207 -0
- package/dist/client/googleoauth-consent.d.ts +88 -0
- package/dist/client/googleoauth-consent.js +94 -0
- package/dist/client/googleoauth-consent.tsx +286 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +42 -0
- package/dist/src/googleoauth-autherror.d.ts +25 -0
- package/dist/src/googleoauth-autherror.js +144 -0
- package/dist/src/googleoauth-budget.d.ts +48 -0
- package/dist/src/googleoauth-budget.js +121 -0
- package/dist/src/googleoauth-capabilities.d.ts +3 -0
- package/dist/src/googleoauth-capabilities.js +1651 -0
- package/dist/src/googleoauth-conformance.d.ts +10 -0
- package/dist/src/googleoauth-conformance.js +426 -0
- package/dist/src/googleoauth-connector.d.ts +70 -0
- package/dist/src/googleoauth-connector.js +244 -0
- package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
- package/dist/src/googleoauth-consent-client.gen.js +10 -0
- package/dist/src/googleoauth-consent-ui.d.ts +25 -0
- package/dist/src/googleoauth-consent-ui.js +102 -0
- package/dist/src/googleoauth-jwt.d.ts +78 -0
- package/dist/src/googleoauth-jwt.js +183 -0
- package/dist/src/googleoauth-scopes.d.ts +36 -0
- package/dist/src/googleoauth-scopes.js +92 -0
- package/dist/src/googleoauth-server.d.ts +34 -0
- package/dist/src/googleoauth-server.js +89 -0
- package/dist/src/googleoauth-store.d.ts +78 -0
- package/dist/src/googleoauth-store.js +313 -0
- package/dist/src/googleoauth-twin.d.ts +53 -0
- package/dist/src/googleoauth-twin.js +1050 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +102 -0
- package/package.json +75 -0
- package/src/cli.ts +41 -0
- package/src/googleoauth-autherror.ts +150 -0
- package/src/googleoauth-budget.ts +147 -0
- package/src/googleoauth-capabilities.ts +1775 -0
- package/src/googleoauth-conformance.ts +472 -0
- package/src/googleoauth-connector.ts +266 -0
- package/src/googleoauth-consent-client.gen.ts +10 -0
- package/src/googleoauth-consent-ui.ts +124 -0
- package/src/googleoauth-journey.uitest.ts +296 -0
- package/src/googleoauth-jwt.ts +207 -0
- package/src/googleoauth-scopes.ts +109 -0
- package/src/googleoauth-server.ts +101 -0
- package/src/googleoauth-store.ts +359 -0
- package/src/googleoauth-twin.ts +1207 -0
- 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;
|