@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
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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.js";
|
|
11
|
+
import { DEFAULT_CLIENT_ID, defaultRedirectUris } from "./googleoauth-store.js";
|
|
12
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
13
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
14
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
15
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
16
|
+
if (cmd === 'serve' || cmd === 'mirror') {
|
|
17
|
+
const s = await createGoogleOAuthTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
18
|
+
const origin = `http://127.0.0.1:${s.port}`;
|
|
19
|
+
process.stdout.write(`googleoauth twin (OAuth 2.0 / OIDC)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
|
|
20
|
+
if (cmd === 'mirror') {
|
|
21
|
+
const url = new URL(`${origin}/o/oauth2/v2/auth`);
|
|
22
|
+
url.searchParams.set('client_id', DEFAULT_CLIENT_ID);
|
|
23
|
+
url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]);
|
|
24
|
+
url.searchParams.set('response_type', 'code');
|
|
25
|
+
url.searchParams.set('scope', 'openid email profile https://www.googleapis.com/auth/calendar.readonly');
|
|
26
|
+
url.searchParams.set('state', 'twin-demo-state');
|
|
27
|
+
url.searchParams.set('access_type', 'offline');
|
|
28
|
+
process.stdout.write(`consent screen: ${url.toString()}\n`);
|
|
29
|
+
}
|
|
30
|
+
await keepProcessAlive();
|
|
31
|
+
}
|
|
32
|
+
else if (cmd === 'conformance') {
|
|
33
|
+
// dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
|
|
34
|
+
const { checkGoogleOAuthConformance } = await import("./googleoauth-conformance.js");
|
|
35
|
+
const report = await checkGoogleOAuthConformance({ ...(root ? { root } : {}) });
|
|
36
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
37
|
+
if (!report.ok)
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
process.stdout.write('Usage: world-googleoauth serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const ERROR_PAGE_PATH = "/signin/oauth/error";
|
|
2
|
+
/** Google puts this on every OAuth error-page URL; a real capture always carries it. */
|
|
3
|
+
export declare const FLOW_NAME = "GeneralOAuthFlow";
|
|
4
|
+
export type AuthError = {
|
|
5
|
+
/** The OAuth error code (protobuf field 1). */
|
|
6
|
+
code: string;
|
|
7
|
+
/** The human-readable message (field 2). */
|
|
8
|
+
message: string;
|
|
9
|
+
/** The documentation URL Google links (field 3). */
|
|
10
|
+
docUrl?: string;
|
|
11
|
+
/** The HTTP status the page NAMES in its "Error <status>: <code>" line (field 4). */
|
|
12
|
+
status: number;
|
|
13
|
+
/** The offending parameter, echoed back (field 5). */
|
|
14
|
+
param?: string;
|
|
15
|
+
};
|
|
16
|
+
/** Encode an AuthError as the base64url protobuf Google puts in `?authError=`. */
|
|
17
|
+
export declare function encodeAuthError(e: AuthError): string;
|
|
18
|
+
/** Decode a `?authError=` payload back into its fields. Returns null on anything unparseable. */
|
|
19
|
+
export declare function decodeAuthError(encoded: string): AuthError | null;
|
|
20
|
+
/** The full error-page URL an authorization request is bounced to. */
|
|
21
|
+
export declare function authErrorRedirect(origin: string, e: AuthError, clientId?: string): string;
|
|
22
|
+
/** The two headline strings Google's error pages use, chosen by error code (captured verbatim). */
|
|
23
|
+
export declare function errorHeadline(code: string): string;
|
|
24
|
+
/** Google's documentation URLs, per error code (captured from real `authError` payloads). */
|
|
25
|
+
export declare const ERROR_DOC_URLS: Record<string, string>;
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
export const ERROR_PAGE_PATH = '/signin/oauth/error';
|
|
31
|
+
/** Google puts this on every OAuth error-page URL; a real capture always carries it. */
|
|
32
|
+
export const FLOW_NAME = 'GeneralOAuthFlow';
|
|
33
|
+
function varint(n) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let v = n;
|
|
36
|
+
while (v > 0x7f) {
|
|
37
|
+
out.push((v & 0x7f) | 0x80);
|
|
38
|
+
v >>>= 7;
|
|
39
|
+
}
|
|
40
|
+
out.push(v);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function stringField(field, value) {
|
|
44
|
+
const bytes = Array.from(Buffer.from(value, 'utf8'));
|
|
45
|
+
return [(field << 3) | 2, ...varint(bytes.length), ...bytes];
|
|
46
|
+
}
|
|
47
|
+
/** Encode an AuthError as the base64url protobuf Google puts in `?authError=`. */
|
|
48
|
+
export function encodeAuthError(e) {
|
|
49
|
+
const out = [
|
|
50
|
+
...stringField(1, e.code),
|
|
51
|
+
...stringField(2, e.message),
|
|
52
|
+
...(e.docUrl ? stringField(3, e.docUrl) : []),
|
|
53
|
+
(4 << 3) | 0,
|
|
54
|
+
...varint(e.status),
|
|
55
|
+
...(e.param ? stringField(5, e.param) : []),
|
|
56
|
+
];
|
|
57
|
+
return Buffer.from(Uint8Array.from(out)).toString('base64url');
|
|
58
|
+
}
|
|
59
|
+
/** Decode a `?authError=` payload back into its fields. Returns null on anything unparseable. */
|
|
60
|
+
export function decodeAuthError(encoded) {
|
|
61
|
+
let buf;
|
|
62
|
+
try {
|
|
63
|
+
buf = Buffer.from(encoded, 'base64url');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
let i = 0;
|
|
70
|
+
const readVarint = () => {
|
|
71
|
+
let result = 0;
|
|
72
|
+
let shift = 0;
|
|
73
|
+
while (i < buf.length) {
|
|
74
|
+
const byte = buf[i++];
|
|
75
|
+
result |= (byte & 0x7f) << shift;
|
|
76
|
+
if ((byte & 0x80) === 0)
|
|
77
|
+
return result >>> 0;
|
|
78
|
+
shift += 7;
|
|
79
|
+
if (shift > 28)
|
|
80
|
+
return -1;
|
|
81
|
+
}
|
|
82
|
+
return -1;
|
|
83
|
+
};
|
|
84
|
+
while (i < buf.length) {
|
|
85
|
+
const tag = readVarint();
|
|
86
|
+
if (tag < 0)
|
|
87
|
+
return null;
|
|
88
|
+
const field = tag >>> 3;
|
|
89
|
+
const wire = tag & 7;
|
|
90
|
+
if (wire === 0) {
|
|
91
|
+
const value = readVarint();
|
|
92
|
+
if (value < 0)
|
|
93
|
+
return null;
|
|
94
|
+
if (field === 4)
|
|
95
|
+
out.status = value;
|
|
96
|
+
}
|
|
97
|
+
else if (wire === 2) {
|
|
98
|
+
const len = readVarint();
|
|
99
|
+
if (len < 0 || i + len > buf.length)
|
|
100
|
+
return null;
|
|
101
|
+
const value = buf.subarray(i, i + len).toString('utf8');
|
|
102
|
+
i += len;
|
|
103
|
+
if (field === 1)
|
|
104
|
+
out.code = value;
|
|
105
|
+
else if (field === 2)
|
|
106
|
+
out.message = value;
|
|
107
|
+
else if (field === 3)
|
|
108
|
+
out.docUrl = value;
|
|
109
|
+
else if (field === 5)
|
|
110
|
+
out.param = value;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
return null; // a wire type Google's payload never uses
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!out.code || out.message === undefined || out.status === undefined)
|
|
117
|
+
return null;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** The full error-page URL an authorization request is bounced to. */
|
|
121
|
+
export function authErrorRedirect(origin, e, clientId) {
|
|
122
|
+
const url = new URL(`${origin}${ERROR_PAGE_PATH}`);
|
|
123
|
+
url.searchParams.set('authError', encodeAuthError(e));
|
|
124
|
+
url.searchParams.set('flowName', FLOW_NAME);
|
|
125
|
+
if (clientId)
|
|
126
|
+
url.searchParams.set('client_id', clientId);
|
|
127
|
+
return url.toString();
|
|
128
|
+
}
|
|
129
|
+
/** The two headline strings Google's error pages use, chosen by error code (captured verbatim). */
|
|
130
|
+
export function errorHeadline(code) {
|
|
131
|
+
// `redirect_uri_mismatch` is an app-configuration failure, and Google words it as one; every
|
|
132
|
+
// other code lands on the generic authorization-error headline.
|
|
133
|
+
return code === 'redirect_uri_mismatch'
|
|
134
|
+
? "Access blocked: This app's request is invalid"
|
|
135
|
+
: 'Access blocked: Authorization Error';
|
|
136
|
+
}
|
|
137
|
+
/** Google's documentation URLs, per error code (captured from real `authError` payloads). */
|
|
138
|
+
export const ERROR_DOC_URLS = {
|
|
139
|
+
redirect_uri_mismatch: 'https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation',
|
|
140
|
+
invalid_client: 'https://developers.google.com/identity/protocols/oauth2',
|
|
141
|
+
invalid_request: 'https://developers.google.com/identity/protocols/oauth2/web-server',
|
|
142
|
+
invalid_scope: 'https://developers.google.com/identity/protocols/oauth2/scopes',
|
|
143
|
+
unsupported_response_type: 'https://developers.google.com/identity/protocols/oauth2/web-server',
|
|
144
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
|
|
2
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
3
|
+
export declare const GOOGLEOAUTH_BUDGET_WINDOW_MS = 60000;
|
|
4
|
+
/**
|
|
5
|
+
* Weighted units allowed inside one window. 60/60s = 30 calls a minute at the default weight —
|
|
6
|
+
* EXACTLY the kernel fallback's burst, because Google publishes no rate this could be measured
|
|
7
|
+
* against. See the header.
|
|
8
|
+
*/
|
|
9
|
+
export declare const GOOGLEOAUTH_BUDGET_CEILING = 60;
|
|
10
|
+
/** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
|
|
11
|
+
export declare const GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
12
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is judged vs. documented. */
|
|
13
|
+
export declare const GOOGLEOAUTH_CALL_WEIGHTS: {
|
|
14
|
+
/** `POST /revoke` — destroys a whole grant on a REAL account. The only destructive call here. */
|
|
15
|
+
readonly revoke: 10;
|
|
16
|
+
/** Everything else: discovery, certs, userinfo, tokeninfo. */
|
|
17
|
+
readonly other: 2;
|
|
18
|
+
};
|
|
19
|
+
/** THE PACK'S DECLARATION — pure data, the only Google-specific thing in the whole budget. */
|
|
20
|
+
export declare const GOOGLEOAUTH_RATE_BUDGET: RateBudgetDeclaration;
|
|
21
|
+
/**
|
|
22
|
+
* Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
|
|
23
|
+
* (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
|
|
24
|
+
* evaded by input variation: `fetch` upper-cases a known method before sending, so
|
|
25
|
+
* `execute('post', '/revoke')` really does issue the destructive call and must be priced as one.
|
|
26
|
+
*/
|
|
27
|
+
export declare function googleOAuthCallWeight(method: string, path: string): number;
|
|
28
|
+
/** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to opt
|
|
29
|
+
* into world-scoped accounting. */
|
|
30
|
+
export declare function googleOAuthBudgetPath(opts?: {
|
|
31
|
+
root?: string;
|
|
32
|
+
token?: string;
|
|
33
|
+
} | string): string;
|
|
34
|
+
/** Construction options. The vendor is fixed; everything else may only TIGHTEN. */
|
|
35
|
+
export type GoogleOAuthBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
36
|
+
/**
|
|
37
|
+
* This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not an
|
|
38
|
+
* alias, so `budget instanceof GoogleOAuthBudget` means "a budget that accounts against
|
|
39
|
+
* GOOGLEOAUTH's ledger under GOOGLEOAUTH's ceiling": another vendor's `RateBudget` (with its own,
|
|
40
|
+
* possibly larger, ceiling) is NOT assignable there.
|
|
41
|
+
*/
|
|
42
|
+
export declare class GoogleOAuthBudget extends RateBudget {
|
|
43
|
+
constructor(opts?: GoogleOAuthBudgetOptions);
|
|
44
|
+
}
|
|
45
|
+
export { RateBudgetError as GoogleOAuthBudgetError } from '@volter/world-core';
|
|
46
|
+
export type { RateBudgetErrorKind as GoogleOAuthBudgetErrorKind } from '@volter/world-core';
|
|
47
|
+
export type GoogleOAuthBudgetReservation = RateBudgetReservation;
|
|
48
|
+
export type GoogleOAuthBudgetSnapshot = RateBudgetSnapshot;
|
|
@@ -0,0 +1,121 @@
|
|
|
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 { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
|
|
38
|
+
const VENDOR = 'googleoauth';
|
|
39
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
40
|
+
export const GOOGLEOAUTH_BUDGET_WINDOW_MS = 60_000;
|
|
41
|
+
/**
|
|
42
|
+
* Weighted units allowed inside one window. 60/60s = 30 calls a minute at the default weight —
|
|
43
|
+
* EXACTLY the kernel fallback's burst, because Google publishes no rate this could be measured
|
|
44
|
+
* against. See the header.
|
|
45
|
+
*/
|
|
46
|
+
export const GOOGLEOAUTH_BUDGET_CEILING = 60;
|
|
47
|
+
/** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
|
|
48
|
+
export const GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
49
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is judged vs. documented. */
|
|
50
|
+
export const GOOGLEOAUTH_CALL_WEIGHTS = {
|
|
51
|
+
/** `POST /revoke` — destroys a whole grant on a REAL account. The only destructive call here. */
|
|
52
|
+
revoke: 10,
|
|
53
|
+
/** Everything else: discovery, certs, userinfo, tokeninfo. */
|
|
54
|
+
other: 2,
|
|
55
|
+
};
|
|
56
|
+
/** THE PACK'S DECLARATION — pure data, the only Google-specific thing in the whole budget. */
|
|
57
|
+
export const GOOGLEOAUTH_RATE_BUDGET = {
|
|
58
|
+
windowMs: GOOGLEOAUTH_BUDGET_WINDOW_MS,
|
|
59
|
+
ceiling: GOOGLEOAUTH_BUDGET_CEILING,
|
|
60
|
+
defaultWeight: GOOGLEOAUTH_CALL_WEIGHTS.other,
|
|
61
|
+
maxRetryAfterSeconds: GOOGLEOAUTH_BUDGET_MAX_RETRY_AFTER_S,
|
|
62
|
+
rules: [{ match: '^POST /revoke$', weight: GOOGLEOAUTH_CALL_WEIGHTS.revoke }],
|
|
63
|
+
reason: 'Google publishes NO scalar per-minute rate limit for its OAuth 2.0 / OIDC endpoints '
|
|
64
|
+
+ '(accounts.google.com, oauth2.googleapis.com, www.googleapis.com/oauth2/*). What it does '
|
|
65
|
+
+ 'publish for this surface is quota of a different SHAPE — per-user/per-client TOKEN COUNTS, '
|
|
66
|
+
+ 'not a request rate: at most 100 refresh tokens per Google Account per OAuth client (the '
|
|
67
|
+
+ '101st silently invalidates the oldest), a 7-day refresh-token expiry for apps still in '
|
|
68
|
+
+ 'testing status, and a per-client cap on unexpired refresh tokens '
|
|
69
|
+
+ '(developers.google.com/identity/protocols/oauth2, "Refresh token expiration"). A token-count '
|
|
70
|
+
+ 'cap cannot anchor a rolling-window rate, so this declaration does NOT claim it does and does '
|
|
71
|
+
+ "NOT out-burst the kernel's undeclared fallback: 60 weighted units / 60s at defaultWeight 2 is "
|
|
72
|
+
+ '30 calls a minute, exactly the fallback. POST /revoke costs 10 (it destroys the WHOLE grant '
|
|
73
|
+
+ 'on a real account — every access and refresh token for that client+user — which is a human '
|
|
74
|
+
+ 'blast radius, not a throttle). It is the ONLY rule: a rule at the default weight changes no '
|
|
75
|
+
+ 'price and cannot be pinned by any test, so there is none. That price is a judgement call, not '
|
|
76
|
+
+ 'a published cost. The window bounds the 60s AVERAGE and does not pace; the 429 / Retry-After '
|
|
77
|
+
+ 'cooldown is the backstop for a sub-second burst.',
|
|
78
|
+
};
|
|
79
|
+
// Declared at module load, so merely importing this module (which `googleoauth-connector.ts` does)
|
|
80
|
+
// is enough to arm the real ceiling.
|
|
81
|
+
declareRateBudget(VENDOR, GOOGLEOAUTH_RATE_BUDGET);
|
|
82
|
+
/**
|
|
83
|
+
* Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
|
|
84
|
+
* (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
|
|
85
|
+
* evaded by input variation: `fetch` upper-cases a known method before sending, so
|
|
86
|
+
* `execute('post', '/revoke')` really does issue the destructive call and must be priced as one.
|
|
87
|
+
*/
|
|
88
|
+
export function googleOAuthCallWeight(method, path) {
|
|
89
|
+
const { bare, query } = splitQuery(path);
|
|
90
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
91
|
+
}
|
|
92
|
+
function splitQuery(path) {
|
|
93
|
+
const at = path.indexOf('?');
|
|
94
|
+
const query = {};
|
|
95
|
+
if (at !== -1)
|
|
96
|
+
for (const [k, v] of new URLSearchParams(path.slice(at + 1)))
|
|
97
|
+
query[k] = v;
|
|
98
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
99
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
100
|
+
return { bare, query };
|
|
101
|
+
}
|
|
102
|
+
/** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to opt
|
|
103
|
+
* into world-scoped accounting. */
|
|
104
|
+
export function googleOAuthBudgetPath(opts = {}) {
|
|
105
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
106
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
|
|
107
|
+
// excess-property check only catches object literals) must not redirect this pack's ledger.
|
|
108
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not an
|
|
112
|
+
* alias, so `budget instanceof GoogleOAuthBudget` means "a budget that accounts against
|
|
113
|
+
* GOOGLEOAUTH's ledger under GOOGLEOAUTH's ceiling": another vendor's `RateBudget` (with its own,
|
|
114
|
+
* possibly larger, ceiling) is NOT assignable there.
|
|
115
|
+
*/
|
|
116
|
+
export class GoogleOAuthBudget extends RateBudget {
|
|
117
|
+
constructor(opts = {}) {
|
|
118
|
+
super({ ...opts, vendor: VENDOR });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export { RateBudgetError as GoogleOAuthBudgetError } from '@volter/world-core';
|