@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,25 @@
1
+ import { type ConsentView, type ErrorPageProps } from '../client/googleoauth-consent.js';
2
+ export type { ConsentAccount, ConsentScopeRow, ConsentView } from '../client/googleoauth-consent.js';
3
+ export { GOOGLEOAUTH_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, GOOGLEOAUTH_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from './googleoauth-consent-client.gen.js';
4
+ /** Twin-only asset paths. Google serves its consent assets from accounts.google.com too, but under
5
+ * paths we have not read; these are clearly namespaced so they can never be mistaken for vendor
6
+ * surface. */
7
+ export declare const CONSENT_SCRIPT_PATH = "/_twin/assets/consent.js";
8
+ export declare const CONSENT_STYLE_PATH = "/_twin/assets/consent.css";
9
+ /**
10
+ * THE STATE BUILDER — the consent screen's entire view model, folded out of the kernel projection.
11
+ *
12
+ * Returns `null` when the authorization request is unknown (the caller renders the vendor's error
13
+ * page); it never invents an app name, an account or a scope row.
14
+ */
15
+ export declare function googleOAuthConsentState(opts: {
16
+ root?: string;
17
+ requestId: string;
18
+ /** Present ⇒ render the CONSENT step for that account; absent ⇒ the account CHOOSER. */
19
+ sub?: string | null;
20
+ origin: string;
21
+ }): ConsentView | null;
22
+ /** Render the consent (or account-chooser) page from a view model. */
23
+ export declare function consentPageHtml(view: ConsentView, base?: string): string;
24
+ /** Render the vendor's browser error page (an un-redirectable failure). */
25
+ export declare function errorPageHtml(props: ErrorPageProps, base?: string): string;
@@ -0,0 +1,102 @@
1
+ // Google OAuth twin — the SERVER HALF of the consent screen.
2
+ //
3
+ // Named `-consent-ui` rather than `-mirror-ui` on purpose, and that name IS the class doctrine: a
4
+ // dashboard mirror is a SECOND renderer this repo writes over a vendor's data, whereas this is the
5
+ // vendor's OWN page, on the vendor's OWN path, in the middle of the vendor's OWN protocol. The
6
+ // mirror DISCIPLINE still applies in full — one renderer, data-coupled to the projection, driven by
7
+ // a real browser journey — but calling it a mirror would misdescribe what it is.
8
+ //
9
+ // The pieces:
10
+ // • `googleOAuthConsentState` — THE STATE BUILDER. Everything on screen is derived here, from the
11
+ // kernel projection, and nowhere else. It is the seam `scripts/mutation-test.ts` sabotages in
12
+ // the mirror phase (TWIN-20/B9): with it dead the screen has no app name, no accounts and no
13
+ // scopes, so every UI capability must go red while the write/handler path stays real.
14
+ // • `consentPageHtml` / `errorPageHtml` — server-render the REAL exported React components with
15
+ // `renderToStaticMarkup`. There is no template string of markup anywhere: what the browser gets
16
+ // IS the component tree the verifies assert over.
17
+ // • `CONSENT_CLIENT_JS` / `CONSENT_CLIENT_CSS` — the browser bundle and stylesheet as COMMITTED TEXT
18
+ // (`googleoauth-consent-client.gen.ts`, written by `scripts/consent-clients.ts` and drift-gated):
19
+ // progressive enhancement only, and the serve path never builds (runtime contract R12b, R9).
20
+ import { createElement } from 'react';
21
+ import { renderToStaticMarkup } from 'react-dom/server';
22
+ import { ConsentPage, ErrorPage, } from "../client/googleoauth-consent.js";
23
+ import { describeScopes, isGranularlyDeclinable, parseScopeParam, sortScopesForConsent } from "./googleoauth-scopes.js";
24
+ import { listAccounts, readOne } from "./googleoauth-store.js";
25
+ export { GOOGLEOAUTH_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, GOOGLEOAUTH_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from "./googleoauth-consent-client.gen.js";
26
+ /** Twin-only asset paths. Google serves its consent assets from accounts.google.com too, but under
27
+ * paths we have not read; these are clearly namespaced so they can never be mistaken for vendor
28
+ * surface. */
29
+ export const CONSENT_SCRIPT_PATH = '/_twin/assets/consent.js';
30
+ export const CONSENT_STYLE_PATH = '/_twin/assets/consent.css';
31
+ function toConsentAccount(row) {
32
+ return {
33
+ sub: row.id,
34
+ email: String(row.email ?? ''),
35
+ name: String(row.name ?? ''),
36
+ givenName: String(row.givenName ?? ''),
37
+ picture: String(row.picture ?? ''),
38
+ ...(row.hd ? { hd: String(row.hd) } : {}),
39
+ };
40
+ }
41
+ /**
42
+ * THE STATE BUILDER — the consent screen's entire view model, folded out of the kernel projection.
43
+ *
44
+ * Returns `null` when the authorization request is unknown (the caller renders the vendor's error
45
+ * page); it never invents an app name, an account or a scope row.
46
+ */
47
+ export function googleOAuthConsentState(opts) {
48
+ const authRequest = readOne(opts.root, 'auth_request', opts.requestId);
49
+ if (!authRequest)
50
+ return null;
51
+ const client = readOne(opts.root, 'oauth_client', String(authRequest.clientId));
52
+ if (!client)
53
+ return null;
54
+ const accounts = listAccounts(opts.root).map(toConsentAccount);
55
+ const requested = sortScopesForConsent(parseScopeParam(String(authRequest.scope ?? '')));
56
+ const scopes = describeScopes(requested).map((s) => ({
57
+ scope: s.scope,
58
+ label: s.label,
59
+ group: s.group,
60
+ ...(s.sensitivity ? { sensitivity: s.sensitivity } : {}),
61
+ known: s.known,
62
+ declinable: isGranularlyDeclinable(s.scope),
63
+ }));
64
+ const account = opts.sub ? accounts.find((a) => a.sub === opts.sub) : undefined;
65
+ return {
66
+ step: account ? 'consent' : 'choose',
67
+ requestId: opts.requestId,
68
+ origin: opts.origin,
69
+ app: {
70
+ clientId: client.id,
71
+ name: String(client.name ?? ''),
72
+ supportEmail: String(client.supportEmail ?? ''),
73
+ verified: client.verified === true,
74
+ },
75
+ accounts,
76
+ scopes,
77
+ ...(account ? { account } : {}),
78
+ };
79
+ }
80
+ /** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount path),
81
+ * so the page's assets resolve inside the World's mount; empty for an in-process render. */
82
+ function page(title, bodyMarkup, base) {
83
+ // The `<title>` is read by the bundled client (`hydrateConsentControls`) to name the app in the
84
+ // granular-consent hint, so it carries the app name exactly as Google's own tab title does.
85
+ return `<!doctype html>
86
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
87
+ <title>${escapeHtml(title)}</title><link rel="stylesheet" href="${base}${CONSENT_STYLE_PATH}"></head>
88
+ <body>${bodyMarkup}<script type="module" src="${base}${CONSENT_SCRIPT_PATH}"></script></body></html>`;
89
+ }
90
+ function escapeHtml(s) {
91
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
92
+ }
93
+ /** Render the consent (or account-chooser) page from a view model. */
94
+ export function consentPageHtml(view, base = '') {
95
+ const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
96
+ const title = view.step === 'choose' ? `${view.app.name} — Choose an account` : `${view.app.name} — Sign in with Google`;
97
+ return page(title, markup, base);
98
+ }
99
+ /** Render the vendor's browser error page (an un-redirectable failure). */
100
+ export function errorPageHtml(props, base = '') {
101
+ return page(`Error ${props.status}: ${props.code}`, renderToStaticMarkup(createElement(ErrorPage, props)), base);
102
+ }
@@ -0,0 +1,78 @@
1
+ export type GoogleOAuthKeypair = {
2
+ kid: string;
3
+ publicKeyPem: string;
4
+ privateKeyPem: string;
5
+ };
6
+ export type KeypairMaterial = {
7
+ publicKeyPem: string;
8
+ privateKeyPem: string;
9
+ };
10
+ export type KeypairGenerator = () => KeypairMaterial;
11
+ /** Override how `ensureKeypair` mints a new keypair; pass `null` to restore the node:crypto default. */
12
+ export declare function setKeypairGenerator(generator: KeypairGenerator | null): void;
13
+ /**
14
+ * The twin's RSA signing keypair for `root`, generated + persisted on first use and stable
15
+ * thereafter — so signing and the served JWKS share ONE key. Deterministic `kid` derived from the
16
+ * public key's DER, so the JWKS `kid` matches the token header `kid` (what a verifier keys on).
17
+ */
18
+ export declare function ensureKeypair(root?: string): Promise<GoogleOAuthKeypair>;
19
+ export declare function base64url(input: Buffer | string): string;
20
+ /**
21
+ * Sign a JWT (RS256) with the twin's persisted private key. Header carries `alg`/`kid`/`typ`
22
+ * exactly as Google's does. Pure local crypto — no network.
23
+ */
24
+ export declare function signJwt(claims: Record<string, unknown>, opts?: {
25
+ root?: string;
26
+ expiresInSeconds?: number;
27
+ now?: number;
28
+ }): Promise<string>;
29
+ /** The decoded parts of a JWT (NO verification). */
30
+ export declare function decodeJwt(token: string): {
31
+ header: Record<string, unknown>;
32
+ payload: Record<string, unknown>;
33
+ };
34
+ export type Jwk = {
35
+ kty: 'RSA';
36
+ use: 'sig';
37
+ alg: 'RS256';
38
+ kid: string;
39
+ n: string;
40
+ e: string;
41
+ };
42
+ export type Jwks = {
43
+ keys: Jwk[];
44
+ };
45
+ /**
46
+ * Verify a token's RS256 signature against a JWKS document (the SAME shape the twin serves at
47
+ * /oauth2/v3/certs) and check exp/nbf. This is what a relying party does to trust an id_token.
48
+ * Pure local crypto — no network.
49
+ */
50
+ export declare function verifyJwtWithJwks(token: string, jwks: Jwks, opts?: {
51
+ now?: number;
52
+ }): {
53
+ valid: boolean;
54
+ payload?: Record<string, unknown>;
55
+ reason?: string;
56
+ };
57
+ /** Build the JWKS document served at /oauth2/v3/certs — Google's real shape, from the SAME
58
+ * persisted key used to sign. */
59
+ export declare function buildJwks(root?: string): Promise<Jwks>;
60
+ /**
61
+ * Google's legacy PEM certificate endpoint (`/oauth2/v1/certs`) answers `{ "<kid>": "<PEM>" }`.
62
+ * `google-auth-library` still reads it when `certificateCacheFormat` is PEM, so the twin serves
63
+ * the SAME persisted key in that shape too rather than 404ing a documented endpoint.
64
+ *
65
+ * Google publishes X.509 CERTIFICATES there; the twin has no CA and refuses to fabricate a
66
+ * certificate chain, so it publishes the SPKI PUBLIC KEY PEM for the same key instead. That
67
+ * difference is stated in the README and filed as `googleoauth.certs.v1_x509_certificate` (todo)
68
+ * — never dressed up as a certificate.
69
+ */
70
+ export declare function buildLegacyPemCerts(root?: string): Promise<Record<string, string>>;
71
+ /**
72
+ * OIDC `at_hash` — base64url of the LEFT HALF of SHA-256(access_token). Google puts it in every
73
+ * id_token minted through the authorization-code flow, and `openid-client` VALIDATES it when
74
+ * present, so computing it wrong breaks a real client. (OpenID Connect Core 1.0 §3.1.3.6.)
75
+ */
76
+ export declare function atHash(accessToken: string): string;
77
+ /** PKCE S256 transform: BASE64URL(SHA256(ASCII(code_verifier))) — RFC 7636 §4.2. */
78
+ export declare function pkceS256(codeVerifier: string): string;
@@ -0,0 +1,183 @@
1
+ // Google OAuth twin — REAL RS256 crypto (the clerk-jwt.ts precedent, adapted to Google's OIDC).
2
+ //
3
+ // Google's id_token is a genuine RS256 JWT signed by a key whose public half is published at
4
+ // https://www.googleapis.com/oauth2/v3/certs. Every real relying party — `google-auth-library`'s
5
+ // `verifyIdToken`, `openid-client`, NextAuth, passport-google-oidc — VERIFIES that signature
6
+ // against that JWKS. A twin that emitted an `alg: none` stub would be refused by every one of
7
+ // them, so the honest twin does what clerk does: generate an RSA-2048 keypair on first use,
8
+ // PERSIST it in kernel state (so signing and the served JWKS are the SAME key and survive a
9
+ // restart), sign real tokens, and serve a real JWKS.
10
+ //
11
+ // `node:crypto` is required LAZILY (inside the functions that use it), NOT as a top-level static
12
+ // import, so this module can be pulled into the browser bundle of the consent client without Bun's
13
+ // browser target choking on it — the signing helpers are server-only and never reached there.
14
+ import { applyTwinWrite, projectResources, nodeBuiltin } from '@volter/world-core';
15
+ function nodeCrypto() {
16
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
17
+ return nodeBuiltin('node:crypto');
18
+ }
19
+ const SERVICE = 'googleoauth';
20
+ // The keypair is stored as a single twin-internal resource so it persists in kernel state (the
21
+ // SAME substrate the rest of the twin uses — no parallel store). `_`-prefixed fields are
22
+ // twin-internal.
23
+ const KEYPAIR_TYPE = '_keypair';
24
+ const KEYPAIR_ID = 'googleoauth_signing_key';
25
+ function nodeGenerateKeypair() {
26
+ const { generateKeyPairSync } = nodeCrypto();
27
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
28
+ modulusLength: 2048,
29
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
30
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
31
+ });
32
+ return { publicKeyPem: publicKey, privateKeyPem: privateKey };
33
+ }
34
+ // Injectable keypair seam (the clerk `setKeypairGenerator` precedent): a host where RUNTIME RSA
35
+ // keygen is not viable installs a provider returning a PRE-GENERATED keypair. The kid is still
36
+ // derived from the public key by `kidFor`, so a stable baked key yields a STABLE kid.
37
+ const keypairGenerator = { current: nodeGenerateKeypair }; // a slot, not module truth (protocol 2)
38
+ /** Override how `ensureKeypair` mints a new keypair; pass `null` to restore the node:crypto default. */
39
+ export function setKeypairGenerator(generator) {
40
+ keypairGenerator.current = generator ?? nodeGenerateKeypair;
41
+ }
42
+ /**
43
+ * The twin's RSA signing keypair for `root`, generated + persisted on first use and stable
44
+ * thereafter — so signing and the served JWKS share ONE key. Deterministic `kid` derived from the
45
+ * public key's DER, so the JWKS `kid` matches the token header `kid` (what a verifier keys on).
46
+ */
47
+ export async function ensureKeypair(root) {
48
+ const existing = projectResources(SERVICE, root).find((r) => r.type === KEYPAIR_TYPE && r.id === KEYPAIR_ID);
49
+ if (existing && typeof existing._privateKeyPem === 'string' && typeof existing._publicKeyPem === 'string') {
50
+ return { kid: String(existing._kid), publicKeyPem: existing._publicKeyPem, privateKeyPem: existing._privateKeyPem };
51
+ }
52
+ const { publicKeyPem, privateKeyPem } = keypairGenerator.current();
53
+ const kid = kidFor(publicKeyPem);
54
+ await applyTwinWrite(SERVICE, {
55
+ operation: '_keypair.create',
56
+ subjectType: KEYPAIR_TYPE,
57
+ subjectId: KEYPAIR_ID,
58
+ fields: { _kid: kid, _publicKeyPem: publicKeyPem, _privateKeyPem: privateKeyPem },
59
+ actor: { kind: 'system' },
60
+ }, root);
61
+ return { kid, publicKeyPem, privateKeyPem };
62
+ }
63
+ /** Stable key id = first 40 hex of SHA-1-length SHA-256 over the SPKI DER. Google's own kids are
64
+ * opaque 40-hex strings, so the SHAPE is vendor-faithful as well as deterministic per key. */
65
+ function kidFor(publicKeyPem) {
66
+ const { createHash, createPublicKey } = nodeCrypto();
67
+ const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
68
+ return createHash('sha256').update(der).digest('hex').slice(0, 40);
69
+ }
70
+ export function base64url(input) {
71
+ return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
72
+ }
73
+ function base64urlJson(obj) {
74
+ return base64url(JSON.stringify(obj));
75
+ }
76
+ /**
77
+ * Sign a JWT (RS256) with the twin's persisted private key. Header carries `alg`/`kid`/`typ`
78
+ * exactly as Google's does. Pure local crypto — no network.
79
+ */
80
+ export async function signJwt(claims, opts = {}) {
81
+ const { createSign } = nodeCrypto();
82
+ const { kid, privateKeyPem } = await ensureKeypair(opts.root);
83
+ // `iat`/`exp` are SERVED (they ride inside the id_token this returns), so R9 governs them: the
84
+ // caller names the instant — `handleGoogleOAuthTwinRequest` passes the world instant the server
85
+ // stamped with `worldNow()`. The old `?? Math.floor(Date.now()/1000)` fallback put the host's
86
+ // wall clock into a signed token on any path that forgot to say when; it throws now.
87
+ if (opts.now === undefined)
88
+ throw new Error('googleoauth signJwt: `now` is required — this twin has no clock of its own (pass the request\'s world instant)');
89
+ const iat = opts.now;
90
+ const exp = iat + (opts.expiresInSeconds ?? 3600);
91
+ const header = { alg: 'RS256', kid, typ: 'JWT' };
92
+ const payload = { ...claims, iat, exp };
93
+ const signingInput = `${base64urlJson(header)}.${base64urlJson(payload)}`;
94
+ const signer = createSign('RSA-SHA256');
95
+ signer.update(signingInput);
96
+ signer.end();
97
+ return `${signingInput}.${base64url(signer.sign(privateKeyPem))}`;
98
+ }
99
+ /** The decoded parts of a JWT (NO verification). */
100
+ export function decodeJwt(token) {
101
+ const parts = token.split('.');
102
+ if (parts.length !== 3)
103
+ throw new Error('malformed jwt');
104
+ const dec = (s) => JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
105
+ return { header: dec(parts[0]), payload: dec(parts[1]) };
106
+ }
107
+ /**
108
+ * Verify a token's RS256 signature against a JWKS document (the SAME shape the twin serves at
109
+ * /oauth2/v3/certs) and check exp/nbf. This is what a relying party does to trust an id_token.
110
+ * Pure local crypto — no network.
111
+ */
112
+ export function verifyJwtWithJwks(token, jwks, opts = {}) {
113
+ const { createVerify, createPublicKey } = nodeCrypto();
114
+ let header, payload, parts;
115
+ try {
116
+ parts = token.split('.');
117
+ if (parts.length !== 3)
118
+ return { valid: false, reason: 'malformed' };
119
+ ({ header, payload } = decodeJwt(token));
120
+ }
121
+ catch {
122
+ return { valid: false, reason: 'malformed' };
123
+ }
124
+ if (header.alg !== 'RS256')
125
+ return { valid: false, reason: 'unexpected_alg' };
126
+ const key = jwks.keys.find((k) => k.kid === header.kid) ?? jwks.keys[0];
127
+ if (!key)
128
+ return { valid: false, reason: 'no_matching_key' };
129
+ const publicKey = createPublicKey({ key: { kty: 'RSA', n: key.n, e: key.e }, format: 'jwk' });
130
+ const verifier = createVerify('RSA-SHA256');
131
+ verifier.update(`${parts[0]}.${parts[1]}`);
132
+ verifier.end();
133
+ const sig = Buffer.from(parts[2].replace(/-/g, '+').replace(/_/g, '/'), 'base64');
134
+ if (!verifier.verify(publicKey, sig))
135
+ return { valid: false, reason: 'bad_signature' };
136
+ // The relying party names the instant it is verifying AT, for the same reason the signer does:
137
+ // this module is on the pack's serve-path import closure and holds no clock of its own.
138
+ if (opts.now === undefined)
139
+ throw new Error('googleoauth verifyJwtWithJwks: `now` is required — this module has no clock of its own (pass the instant you are verifying at)');
140
+ const now = opts.now;
141
+ if (typeof payload.exp === 'number' && now >= payload.exp)
142
+ return { valid: false, reason: 'expired' };
143
+ if (typeof payload.nbf === 'number' && now < payload.nbf)
144
+ return { valid: false, reason: 'not_yet_valid' };
145
+ return { valid: true, payload };
146
+ }
147
+ /** Build the JWKS document served at /oauth2/v3/certs — Google's real shape, from the SAME
148
+ * persisted key used to sign. */
149
+ export async function buildJwks(root) {
150
+ const { createPublicKey } = nodeCrypto();
151
+ const { kid, publicKeyPem } = await ensureKeypair(root);
152
+ const jwk = createPublicKey(publicKeyPem).export({ format: 'jwk' });
153
+ return { keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid, n: String(jwk.n), e: String(jwk.e) }] };
154
+ }
155
+ /**
156
+ * Google's legacy PEM certificate endpoint (`/oauth2/v1/certs`) answers `{ "<kid>": "<PEM>" }`.
157
+ * `google-auth-library` still reads it when `certificateCacheFormat` is PEM, so the twin serves
158
+ * the SAME persisted key in that shape too rather than 404ing a documented endpoint.
159
+ *
160
+ * Google publishes X.509 CERTIFICATES there; the twin has no CA and refuses to fabricate a
161
+ * certificate chain, so it publishes the SPKI PUBLIC KEY PEM for the same key instead. That
162
+ * difference is stated in the README and filed as `googleoauth.certs.v1_x509_certificate` (todo)
163
+ * — never dressed up as a certificate.
164
+ */
165
+ export async function buildLegacyPemCerts(root) {
166
+ const { kid, publicKeyPem } = await ensureKeypair(root);
167
+ return { [kid]: publicKeyPem };
168
+ }
169
+ /**
170
+ * OIDC `at_hash` — base64url of the LEFT HALF of SHA-256(access_token). Google puts it in every
171
+ * id_token minted through the authorization-code flow, and `openid-client` VALIDATES it when
172
+ * present, so computing it wrong breaks a real client. (OpenID Connect Core 1.0 §3.1.3.6.)
173
+ */
174
+ export function atHash(accessToken) {
175
+ const { createHash } = nodeCrypto();
176
+ const digest = createHash('sha256').update(accessToken).digest();
177
+ return base64url(digest.subarray(0, digest.length / 2));
178
+ }
179
+ /** PKCE S256 transform: BASE64URL(SHA256(ASCII(code_verifier))) — RFC 7636 §4.2. */
180
+ export function pkceS256(codeVerifier) {
181
+ const { createHash } = nodeCrypto();
182
+ return base64url(createHash('sha256').update(codeVerifier, 'ascii').digest());
183
+ }
@@ -0,0 +1,36 @@
1
+ export type ScopeInfo = {
2
+ /** The scope string a client requests. */
3
+ scope: string;
4
+ /** The consent-screen sentence Google shows for it. */
5
+ label: string;
6
+ /** Google's own grouping — OIDC scopes are shown above the granular product scopes. */
7
+ group: 'openid' | 'product';
8
+ /** Google marks a subset "sensitive"/"restricted"; those show the extra review notice. */
9
+ sensitivity?: 'sensitive' | 'restricted';
10
+ };
11
+ /** The three OIDC scopes, which Google treats specially (they mint id_token claims). */
12
+ export declare const OIDC_SCOPES: readonly ["openid", "email", "profile"];
13
+ export declare const SCOPE_CATALOG: ScopeInfo[];
14
+ /** Split a `scope` parameter the way OAuth 2.0 does — space-delimited, order preserved, deduped. */
15
+ export declare function parseScopeParam(raw: string | null | undefined): string[];
16
+ /** Render a scope list back to the wire form Google echoes in `scope` responses. */
17
+ export declare function formatScopeParam(scopes: readonly string[]): string;
18
+ /**
19
+ * The consent-screen row for one scope. An UNKNOWN scope is shown with its raw string as the
20
+ * label (see the honesty note above) and flagged so the caller can tell the two apart.
21
+ */
22
+ export declare function describeScope(scope: string): ScopeInfo & {
23
+ known: boolean;
24
+ };
25
+ /** Every requested scope, in request order, described for the consent screen. */
26
+ export declare function describeScopes(scopes: readonly string[]): Array<ScopeInfo & {
27
+ known: boolean;
28
+ }>;
29
+ /** Google shows the OIDC ("Associate you with…") rows above the granular product rows. */
30
+ export declare function sortScopesForConsent(scopes: readonly string[]): string[];
31
+ /**
32
+ * Granular consent: Google lets a user UNCHECK individual non-OIDC scopes, and the resulting grant
33
+ * carries only what was ticked. The OIDC scopes are not individually declinable — they ride with
34
+ * signing in — so they are always returned.
35
+ */
36
+ export declare function isGranularlyDeclinable(scope: string): boolean;
@@ -0,0 +1,92 @@
1
+ // Google OAuth scope catalog — the SCOPE STRINGS Google publishes
2
+ // (developers.google.com/identity/protocols/oauth2/scopes) paired with the human sentence its
3
+ // consent screen shows for each ("See your primary Google Account email address", …).
4
+ //
5
+ // This file is PURE data + pure functions and is imported by the browser consent client as well as
6
+ // the server, so it must stay free of `@volter/world-core`, `node:*` and Bun.
7
+ //
8
+ // HONESTY NOTE. Google's scope list is ~400 entries long and grows with every product; this
9
+ // catalog carries the OIDC core scopes plus the scopes real integrations actually request (the
10
+ // Cal.com/Dub calendar+contacts+drive families). An UNKNOWN scope is NOT an error — Google accepts
11
+ // any scope string a project has enabled — so the twin renders an unknown scope with its raw
12
+ // string rather than inventing a description for it. Fabricating a friendly sentence for a scope
13
+ // we have not read from the vendor would be exactly the "serving surface the vendor doesn't have"
14
+ // false-green ADDING_A_TWIN.md §6 warns about.
15
+ /** The three OIDC scopes, which Google treats specially (they mint id_token claims). */
16
+ export const OIDC_SCOPES = ['openid', 'email', 'profile'];
17
+ export const SCOPE_CATALOG = [
18
+ { scope: 'openid', label: 'Associate you with your personal info on Google', group: 'openid' },
19
+ { scope: 'email', label: 'See your primary Google Account email address', group: 'openid' },
20
+ { scope: 'profile', label: 'See your personal info, including any personal info you\'ve made publicly available', group: 'openid' },
21
+ { scope: 'https://www.googleapis.com/auth/userinfo.email', label: 'See your primary Google Account email address', group: 'openid' },
22
+ { scope: 'https://www.googleapis.com/auth/userinfo.profile', label: 'See your personal info, including any personal info you\'ve made publicly available', group: 'openid' },
23
+ // Calendar — the family every scheduling integration (Cal.com has 12 OAuth integrations) asks for.
24
+ { scope: 'https://www.googleapis.com/auth/calendar', label: 'See, edit, share, and permanently delete all the calendars you can access using Google Calendar', group: 'product', sensitivity: 'sensitive' },
25
+ { scope: 'https://www.googleapis.com/auth/calendar.readonly', label: 'See and download any calendar you can access using your Google Calendar', group: 'product', sensitivity: 'sensitive' },
26
+ { scope: 'https://www.googleapis.com/auth/calendar.events', label: 'View and edit events on all your calendars', group: 'product', sensitivity: 'sensitive' },
27
+ { scope: 'https://www.googleapis.com/auth/calendar.events.readonly', label: 'View events on all your calendars', group: 'product', sensitivity: 'sensitive' },
28
+ // Drive.
29
+ { scope: 'https://www.googleapis.com/auth/drive', label: 'See, edit, create, and delete all of your Google Drive files', group: 'product', sensitivity: 'restricted' },
30
+ { scope: 'https://www.googleapis.com/auth/drive.file', label: 'See, edit, create, and delete only the specific Google Drive files you use with this app', group: 'product' },
31
+ { scope: 'https://www.googleapis.com/auth/drive.readonly', label: 'See and download all your Google Drive files', group: 'product', sensitivity: 'restricted' },
32
+ // Gmail.
33
+ { scope: 'https://www.googleapis.com/auth/gmail.readonly', label: 'Read all resources and their metadata—no write operations', group: 'product', sensitivity: 'restricted' },
34
+ { scope: 'https://www.googleapis.com/auth/gmail.send', label: 'Send email on your behalf', group: 'product', sensitivity: 'sensitive' },
35
+ // Contacts / directory.
36
+ { scope: 'https://www.googleapis.com/auth/contacts.readonly', label: 'See and download your contacts', group: 'product', sensitivity: 'sensitive' },
37
+ { scope: 'https://www.googleapis.com/auth/directory.readonly', label: 'See and download your organization\'s GSuite directory', group: 'product', sensitivity: 'sensitive' },
38
+ // Spreadsheets — the other integration workhorse.
39
+ { scope: 'https://www.googleapis.com/auth/spreadsheets', label: 'See, edit, create, and delete all your Google Sheets spreadsheets', group: 'product', sensitivity: 'sensitive' },
40
+ { scope: 'https://www.googleapis.com/auth/spreadsheets.readonly', label: 'See all your Google Sheets spreadsheets', group: 'product', sensitivity: 'sensitive' },
41
+ // YouTube — the sibling twin in this repo.
42
+ { scope: 'https://www.googleapis.com/auth/youtube.readonly', label: 'View your YouTube account', group: 'product', sensitivity: 'sensitive' },
43
+ { scope: 'https://www.googleapis.com/auth/youtube.upload', label: 'Manage your YouTube videos', group: 'product', sensitivity: 'sensitive' },
44
+ // Cloud — what a service-account / Vertex flow asks for.
45
+ { scope: 'https://www.googleapis.com/auth/cloud-platform', label: 'See, edit, configure, and delete your Google Cloud data and see the email address for your Google Account', group: 'product', sensitivity: 'sensitive' },
46
+ { scope: 'https://www.googleapis.com/auth/devstorage.read_write', label: 'Manage your data in Cloud Storage and see the email address of your Google Account', group: 'product', sensitivity: 'sensitive' },
47
+ ];
48
+ const BY_SCOPE = new Map(SCOPE_CATALOG.map((s) => [s.scope, s]));
49
+ /** Split a `scope` parameter the way OAuth 2.0 does — space-delimited, order preserved, deduped. */
50
+ export function parseScopeParam(raw) {
51
+ if (typeof raw !== 'string')
52
+ return [];
53
+ const out = [];
54
+ for (const piece of raw.split(/[\s+]+/)) {
55
+ if (piece && !out.includes(piece))
56
+ out.push(piece);
57
+ }
58
+ return out;
59
+ }
60
+ /** Render a scope list back to the wire form Google echoes in `scope` responses. */
61
+ export function formatScopeParam(scopes) {
62
+ return scopes.join(' ');
63
+ }
64
+ /**
65
+ * The consent-screen row for one scope. An UNKNOWN scope is shown with its raw string as the
66
+ * label (see the honesty note above) and flagged so the caller can tell the two apart.
67
+ */
68
+ export function describeScope(scope) {
69
+ const known = BY_SCOPE.get(scope);
70
+ if (known)
71
+ return { ...known, known: true };
72
+ return { scope, label: scope, group: 'product', known: false };
73
+ }
74
+ /** Every requested scope, in request order, described for the consent screen. */
75
+ export function describeScopes(scopes) {
76
+ return scopes.map(describeScope);
77
+ }
78
+ /** Google shows the OIDC ("Associate you with…") rows above the granular product rows. */
79
+ export function sortScopesForConsent(scopes) {
80
+ const described = describeScopes(scopes);
81
+ return [...described].sort((a, b) => (a.group === b.group ? 0 : a.group === 'openid' ? -1 : 1)).map((s) => s.scope);
82
+ }
83
+ /**
84
+ * Granular consent: Google lets a user UNCHECK individual non-OIDC scopes, and the resulting grant
85
+ * carries only what was ticked. The OIDC scopes are not individually declinable — they ride with
86
+ * signing in — so they are always returned.
87
+ */
88
+ export function isGranularlyDeclinable(scope) {
89
+ return !OIDC_SCOPES.includes(scope)
90
+ && scope !== 'https://www.googleapis.com/auth/userinfo.email'
91
+ && scope !== 'https://www.googleapis.com/auth/userinfo.profile';
92
+ }
@@ -0,0 +1,34 @@
1
+ /** Options every Google-OAuth-twin HTTP surface needs, independent of who owns the socket. */
2
+ export interface GoogleOAuthTwinFetchOptions {
3
+ root?: string;
4
+ readOnly?: boolean;
5
+ }
6
+ /**
7
+ * The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
8
+ *
9
+ * This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
10
+ * loopback ports, so it must mount a pack's handler IN-PROCESS. `createGoogleOAuthTwinServer`
11
+ * is nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted
12
+ * surfaces are the SAME code — there is no second HTTP adaptation to drift.
13
+ *
14
+ * WHAT IT SERVES IS UNCHANGED (R9): GET /twin is built from constants, and the only clock on
15
+ * the path is `worldNow()`, the world's frozen instant. The consent screen's own script and
16
+ * stylesheet routes serve COMMITTED text (`googleoauth-consent-client.gen.ts`, built on the dev
17
+ * plane by `scripts/consent-clients.ts`) — no build and no disk read on the fetch path.
18
+ */
19
+ export declare function createGoogleOAuthTwinFetch(options?: GoogleOAuthTwinFetchOptions): (request: Request) => Promise<Response>;
20
+ export declare function createGoogleOAuthTwinServer(options: {
21
+ root?: string;
22
+ port?: number;
23
+ readOnly?: boolean;
24
+ }): Promise<{
25
+ port: number;
26
+ stop: () => void;
27
+ }>;
28
+ /**
29
+ * The consent screen is served BY THE TWIN, at the vendor's own path — there is no second "mirror"
30
+ * server to start, and pretending otherwise would imply a second renderer. This alias exists so the
31
+ * `world-googleoauth mirror` command and the journey harness have the conventional entry point,
32
+ * and it returns the very same server.
33
+ */
34
+ export declare const createGoogleOAuthConsentServer: typeof createGoogleOAuthTwinServer;
@@ -0,0 +1,89 @@
1
+ // Google OAuth twin HTTP server — one server for the WHOLE vendor surface, because Google's OAuth
2
+ // is one product spread over four hostnames. Point `accounts.google.com`, `oauth2.googleapis.com`,
3
+ // `www.googleapis.com/oauth2/*` and `openidconnect.googleapis.com` here (the injector's
4
+ // `googleoauth` VENDOR_HOSTS entry does exactly that) and an unmodified OAuth client completes a
5
+ // full authorization-code round trip against it.
6
+ //
7
+ // Two things this server does that a JSON-only twin server does not:
8
+ // • it can answer with HTML and with 302 redirects — the consent screen and the callback bounce
9
+ // are the protocol, not decoration, so the handler's `headers` (content-type, location) are
10
+ // passed straight through rather than being flattened into a JSON envelope;
11
+ // • it serves the consent page's own assets under the twin-namespaced `/_twin/assets/*`.
12
+ //
13
+ // It also tells the handler its OWN origin, so the OIDC discovery document and the consent form's
14
+ // action point BACK AT THE TWIN. Without that a discovery-driven client (openid-client) would read
15
+ // the document and walk straight out to the real accounts.google.com.
16
+ import { serveHttp } from '@volter/world-core';
17
+ import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from "./googleoauth-consent-ui.js";
18
+ import { worldNow, statefulTwinManifest, twinPublicBase } from '@volter/world-core';
19
+ import { handleGoogleOAuthTwinRequest } from "./googleoauth-twin.js";
20
+ /**
21
+ * The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
22
+ *
23
+ * This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
24
+ * loopback ports, so it must mount a pack's handler IN-PROCESS. `createGoogleOAuthTwinServer`
25
+ * is nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted
26
+ * surfaces are the SAME code — there is no second HTTP adaptation to drift.
27
+ *
28
+ * WHAT IT SERVES IS UNCHANGED (R9): GET /twin is built from constants, and the only clock on
29
+ * the path is `worldNow()`, the world's frozen instant. The consent screen's own script and
30
+ * stylesheet routes serve COMMITTED text (`googleoauth-consent-client.gen.ts`, built on the dev
31
+ * plane by `scripts/consent-clients.ts`) — no build and no disk read on the fetch path.
32
+ */
33
+ export function createGoogleOAuthTwinFetch(options = {}) {
34
+ const readOnly = options.readOnly ?? false;
35
+ return async function googleOAuthTwinFetch(request) {
36
+ const url = new URL(request.url);
37
+ // GET /twin — the discovery manifest (education inside the twin).
38
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
39
+ return Response.json(statefulTwinManifest({ vendor: 'googleoauth', twinOf: 'Google OAuth 2.0 / OpenID Connect', stores: 'authorization codes, tokens and RS256 id_tokens minted by the full code round trip' }));
40
+ }
41
+ if (request.method === 'GET' && url.pathname === CONSENT_SCRIPT_PATH) {
42
+ return new Response(CONSENT_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
43
+ }
44
+ if (request.method === 'GET' && url.pathname === CONSENT_STYLE_PATH) {
45
+ return new Response(CONSENT_CLIENT_CSS, { headers: { 'content-type': 'text/css; charset=utf-8' } });
46
+ }
47
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
48
+ const headers = {};
49
+ request.headers.forEach((value, key) => {
50
+ headers[key.toLowerCase()] = value;
51
+ });
52
+ const res = await handleGoogleOAuthTwinRequest({
53
+ method: request.method,
54
+ path: url.pathname + (url.search || ''),
55
+ body,
56
+ headers,
57
+ readOnly,
58
+ occurredAt: worldNow(),
59
+ origin: twinPublicBase(request),
60
+ callbackOrigin: url.origin,
61
+ ...(options.root !== undefined ? { root: options.root } : {}),
62
+ });
63
+ const out = { ...(res.headers ?? {}) };
64
+ // A string body is already rendered (HTML, or the empty body of a 302); anything else is the
65
+ // vendor's JSON.
66
+ if (typeof res.body === 'string') {
67
+ if (!out['content-type'] && res.body)
68
+ out['content-type'] = 'text/html; charset=utf-8';
69
+ return new Response(res.body, { status: res.status, headers: out });
70
+ }
71
+ out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
72
+ return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
73
+ };
74
+ }
75
+ export async function createGoogleOAuthTwinServer(options) {
76
+ const server = await serveHttp({
77
+ port: options.port ?? 0,
78
+ idleTimeout: 60,
79
+ fetch: createGoogleOAuthTwinFetch(options),
80
+ });
81
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
82
+ }
83
+ /**
84
+ * The consent screen is served BY THE TWIN, at the vendor's own path — there is no second "mirror"
85
+ * server to start, and pretending otherwise would imply a second renderer. This alias exists so the
86
+ * `world-googleoauth mirror` command and the journey harness have the conventional entry point,
87
+ * and it returns the very same server.
88
+ */
89
+ export const createGoogleOAuthConsentServer = createGoogleOAuthTwinServer;