From c01e0722b2458d35e7fc2008fb1440a1d65da779 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:37:01 -0700 Subject: [PATCH] Publish verifiable Google OIDC signing keys --- README.md | 3 ++ apps/web/app/docs/google/page.mdx | 3 ++ packages/@emulators/google/README.md | 3 ++ packages/@emulators/google/src/manifest.ts | 2 +- .../@emulators/google/src/routes/oauth.ts | 51 +++++++++---------- packages/@emulators/google/src/signing.ts | 48 +++++++++++++++++ packages/emulate/src/index.ts | 3 ++ skills/google/SKILL.md | 2 + 8 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 packages/@emulators/google/src/signing.ts diff --git a/README.md b/README.md index f496d6bfa..d6b2fb570 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # emulate + This repository is forked from Vercel Labs' `emulate` project. Useful Software Co maintains this fork to support deployable emulator surfaces and product testing flows for our own development and agent-driven use cases. @@ -819,6 +820,8 @@ Because the full schema is real, this surface is well suited to testing GraphQL ## Google OAuth + Gmail, Calendar, and Drive APIs +Google ID tokens use RS256 and the instance publishes its public signing keys at `/oauth2/v3/certs`. Verify the signature, instance issuer, client audience, expiry and nonce through OIDC discovery. Signing keys are retained with instance state across hosted eviction; resetting the instance replaces them. + OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local inbox, calendar, and drive flows. - `GET /o/oauth2/v2/auth` - authorization endpoint diff --git a/apps/web/app/docs/google/page.mdx b/apps/web/app/docs/google/page.mdx index 1d3dd572f..e1bcc48e8 100644 --- a/apps/web/app/docs/google/page.mdx +++ b/apps/web/app/docs/google/page.mdx @@ -1,5 +1,8 @@ # Google API + +Google ID tokens use RS256 and the instance publishes its public signing keys at `/oauth2/v3/certs`. Verify the signature, instance issuer, client audience, expiry and nonce through OIDC discovery. Signing keys are retained with instance state across hosted eviction; resetting the instance replaces them. + OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local inbox, calendar, and drive flows. ## OAuth & OpenID Connect diff --git a/packages/@emulators/google/README.md b/packages/@emulators/google/README.md index ff3573a3e..a6fd1b484 100644 --- a/packages/@emulators/google/README.md +++ b/packages/@emulators/google/README.md @@ -1,5 +1,8 @@ # @emulators/google + +Google ID tokens use RS256 and the instance publishes its public signing keys at `/oauth2/v3/certs`. Verify the signature, instance issuer, client audience, expiry and nonce through OIDC discovery. Signing keys are retained with instance state across hosted eviction; resetting the instance replaces them. + Google OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local Gmail, Calendar, and Drive flows. Part of [emulate](https://github.com/vercel-labs/emulate) — local drop-in replacement services for CI and no-network sandboxes. diff --git a/packages/@emulators/google/src/manifest.ts b/packages/@emulators/google/src/manifest.ts index 1fe44af04..74e80062d 100644 --- a/packages/@emulators/google/src/manifest.ts +++ b/packages/@emulators/google/src/manifest.ts @@ -36,7 +36,7 @@ export const manifest: ServiceManifest = { path: "/.well-known/openid-configuration", status: "hand-authored", }, - { operationId: "jwks", method: "GET", path: "/oauth2/v3/certs", status: "partial" }, + { operationId: "jwks", method: "GET", path: "/oauth2/v3/certs", status: "hand-authored" }, { operationId: "authorize", method: "GET", path: "/o/oauth2/v2/auth", status: "hand-authored" }, { operationId: "token", method: "POST", path: "/oauth2/token", status: "hand-authored" }, { operationId: "userinfo", method: "GET", path: "/oauth2/v2/userinfo", status: "hand-authored" }, diff --git a/packages/@emulators/google/src/routes/oauth.ts b/packages/@emulators/google/src/routes/oauth.ts index 808bf28f8..efcce05db 100644 --- a/packages/@emulators/google/src/routes/oauth.ts +++ b/packages/@emulators/google/src/routes/oauth.ts @@ -1,5 +1,4 @@ import { createHash, randomBytes } from "crypto"; -import { SignJWT } from "jose"; import type { RouteContext } from "@emulators/core"; import { escapeHtml, @@ -15,8 +14,7 @@ import { } from "@emulators/core"; import { getGoogleStore } from "../store.js"; import type { GoogleUser } from "../entities.js"; - -const JWT_SECRET = new TextEncoder().encode("emulate-google-jwt-secret"); +import { googleSigning } from "../signing.js"; type PendingCode = { email: string; @@ -66,30 +64,29 @@ async function createIdToken( clientId: string, nonce: string | null, baseUrl: string, + signing: ReturnType, ): Promise { - const builder = new SignJWT({ - sub: user.uid, - email: user.email, - email_verified: user.email_verified, - name: user.name, - given_name: user.given_name, - family_name: user.family_name, - picture: user.picture, - locale: user.locale, - ...(user.hd ? { hd: user.hd } : {}), - ...(nonce ? { nonce } : {}), - }) - .setProtectedHeader({ alg: "HS256", typ: "JWT" }) - .setIssuer(baseUrl) - .setAudience(clientId) - .setIssuedAt() - .setExpirationTime("1h"); - - return builder.sign(JWT_SECRET); + return signing.sign( + { + sub: user.uid, + email: user.email, + email_verified: user.email_verified, + name: user.name, + given_name: user.given_name, + family_name: user.family_name, + picture: user.picture, + locale: user.locale, + ...(user.hd ? { hd: user.hd } : {}), + ...(nonce ? { nonce } : {}), + }, + baseUrl, + clientId, + ); } export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { const gs = getGoogleStore(store); + const signing = googleSigning(store); // ---------- OIDC Discovery ---------- @@ -103,7 +100,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo jwks_uri: `${baseUrl}/oauth2/v3/certs`, response_types_supported: ["code"], subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["HS256"], + id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "email", "profile"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], claims_supported: [ @@ -121,10 +118,10 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }); }); - // ---------- JWKS (stub) ---------- + // ---------- Public signing keys ---------- - app.get("/oauth2/v3/certs", (c) => { - return c.json({ keys: [] }); + app.get("/oauth2/v3/certs", async (c) => { + return c.json(await signing.jwks()); }); // Google API Discovery document, pointed at this instance. @@ -387,7 +384,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo clientId: pending.clientId, }); - const idToken = await createIdToken(user, pending.clientId, pending.nonce, baseUrl); + const idToken = await createIdToken(user, pending.clientId, pending.nonce, baseUrl, signing); debug("google.oauth", `[Google token] issued token for ${user.email}`); diff --git a/packages/@emulators/google/src/signing.ts b/packages/@emulators/google/src/signing.ts new file mode 100644 index 000000000..e19c2f9a4 --- /dev/null +++ b/packages/@emulators/google/src/signing.ts @@ -0,0 +1,48 @@ +import { exportJWK, generateKeyPair, importJWK, SignJWT, type JWK, type JWTPayload } from "jose"; +import type { Store } from "@emulators/core"; +import { randomBytes } from "node:crypto"; + +interface SigningKeys { + kid: string; + publicKey: JWK; + privateKey: JWK; +} + +/** Persist each instance's signing material so published keys survive hosted eviction. */ +export function googleSigning(store: Store) { + let pending: Promise | undefined; + const keys = (): Promise => { + const saved = store.getData("google.oauth.signingKeys"); + if (saved) return Promise.resolve(saved); + if (pending) return pending; + pending = (async () => { + const pair = await generateKeyPair("RS256", { extractable: true }); + const value = { + kid: randomBytes(16).toString("hex"), + publicKey: await exportJWK(pair.publicKey), + privateKey: await exportJWK(pair.privateKey), + }; + store.setData("google.oauth.signingKeys", value); + return value; + })().finally(() => { + pending = undefined; + }); + return pending; + }; + return { + async jwks() { + const value = await keys(); + return { keys: [{ ...value.publicKey, kid: value.kid, alg: "RS256", use: "sig" }] }; + }, + async sign(payload: JWTPayload, issuer: string, audience: string) { + const value = await keys(); + return new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", typ: "JWT", kid: value.kid }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime("1h") + .sign(await importJWK(value.privateKey, "RS256")); + }, + }; +} diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index f97c8b69b..ae6aabc2b 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -60,6 +60,9 @@ Global catalog: /_emulate/seed to load fixtures, /_emulate/faults to arm one-shot failures, and /_emulate/ledger to validate API calls. + Google OIDC signs ID tokens with RS256 and publishes verification keys at + /oauth2/v3/certs. Its discovery issuer is the instance URL. + Hosted services: Available services include vercel, github, gitlab, google, slack, apple, microsoft, okta, aws, resend, stripe, mongoatlas, clerk, spotify, x, workos, diff --git a/skills/google/SKILL.md b/skills/google/SKILL.md index 1d8d5e3e6..ecd1f13e5 100644 --- a/skills/google/SKILL.md +++ b/skills/google/SKILL.md @@ -4,6 +4,8 @@ description: Emulated Google OAuth 2.0, OpenID Connect, Gmail, Calendar, and Dri allowed-tools: Bash(npx emulate:*), Bash(emulate:*), Bash(curl:*) --- +Google ID tokens use RS256 and the instance publishes its public signing keys at `/oauth2/v3/certs`. Verify the signature, instance issuer, client audience, expiry and nonce through OIDC discovery. Signing keys are retained with instance state across hosted eviction; resetting the instance replaces them. + # Google OAuth 2.0 / OIDC + Gmail, Calendar & Drive Emulator OAuth 2.0 and OpenID Connect emulation with authorization code flow, PKCE support, ID tokens, OIDC discovery, refresh tokens, plus Gmail, Google Calendar, and Google Drive REST API surfaces.