diff --git a/README.md b/README.md index 6d0823925..9977cab73 100644 --- a/README.md +++ b/README.md @@ -820,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 signatures. Verify them with the public keys at `/oauth2/v3/certs`, the instance issuer, your client ID, and the request nonce. Signing keys last for the server process; restarting it invalidates earlier tokens. + 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..8dd570bdc 100644 --- a/apps/web/app/docs/google/page.mdx +++ b/apps/web/app/docs/google/page.mdx @@ -1,5 +1,7 @@ # Google API +Google ID tokens use RS256 signatures. Verify them with the public keys at `/oauth2/v3/certs`, the instance issuer, your client ID, and the request nonce. Signing keys last for the server process; restarting it invalidates earlier tokens. + 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/src/__tests__/google.test.ts b/packages/@emulators/google/src/__tests__/google.test.ts index 02cf5e441..06c60ec75 100644 --- a/packages/@emulators/google/src/__tests__/google.test.ts +++ b/packages/@emulators/google/src/__tests__/google.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { Hono } from "@emulators/core"; -import { decodeJwt } from "jose"; +import { decodeJwt, jwtVerify, createRemoteJWKSet, customFetch } from "jose"; import { Store, WebhookDispatcher, @@ -897,6 +897,51 @@ describe("Google plugin integration", () => { expect(message.labelIds).not.toContain(created.id); }); + it("verifies ID tokens through discovery keys and rejects tampering and wrong audiences", async () => { + const discovery = await app.request(`${base}/.well-known/openid-configuration`); + expect(await discovery.json()).toMatchObject({ + issuer: base, + jwks_uri: `${base}/oauth2/v3/certs`, + id_token_signing_alg_values_supported: ["RS256"], + }); + const authorized = await formRequest(app, "/o/oauth2/v2/auth/callback", { + email: "testuser@example.com", + redirect_uri: "http://localhost:3000/api/auth/callback/google", + scope: "openid email profile", + client_id: "emu_google_client_id", + nonce: "synthetic-request-nonce", + }); + const location = authorized.headers.get("Location"); + if (location === null) throw new Error("Authorization did not redirect"); + const code = new URL(location).searchParams.get("code"); + if (code === null) throw new Error("Authorization code missing"); + const response = await formRequest(app, "/oauth2/token", { + code, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/api/auth/callback/google", + client_id: "emu_google_client_id", + client_secret: "emu_google_client_secret", + }); + expect(response.status).toBe(200); + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("id_token" in body) || typeof body.id_token !== "string") + throw new Error("Token response must include an ID token"); + const publicKeys = await app.request(`${base}/oauth2/v3/certs`); + expect(await publicKeys.text()).not.toContain('"d":'); + const jwks = createRemoteJWKSet(new URL(`${base}/oauth2/v3/certs`), { + [customFetch]: async (url) => app.request(String(url)), + }); + const options = { issuer: base, audience: "emu_google_client_id", algorithms: ["RS256"] }; + const verified = await jwtVerify(body.id_token, jwks, options); + expect(verified.payload.nonce).toBe("synthetic-request-nonce"); + expect(verified.payload.email).toBe("testuser@example.com"); + expect(verified.protectedHeader.kid).toBeTypeOf("string"); + await expect(jwtVerify(body.id_token, jwks, { ...options, audience: "another-client" })).rejects.toThrow(); + const [header, , signature] = body.id_token.split("."); + const changed = Buffer.from(JSON.stringify({ ...verified.payload, sub: "forged" })).toString("base64url"); + await expect(jwtVerify(`${header}.${changed}.${signature}`, jwks, options)).rejects.toThrow(); + }); + it("exchanges auth codes for refresh tokens and refreshes access tokens", async () => { const authorizeRes = await formRequest(app, "/o/oauth2/v2/auth/callback", { email: "testuser@example.com", diff --git a/packages/@emulators/google/src/routes/oauth.ts b/packages/@emulators/google/src/routes/oauth.ts index 808bf28f8..8b4d96f6c 100644 --- a/packages/@emulators/google/src/routes/oauth.ts +++ b/packages/@emulators/google/src/routes/oauth.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "crypto"; -import { SignJWT } from "jose"; +import { SignJWT, generateKeyPair, exportJWK, calculateJwkThumbprint } from "jose"; import type { RouteContext } from "@emulators/core"; import { escapeHtml, @@ -16,7 +16,17 @@ import { import { getGoogleStore } from "../store.js"; import type { GoogleUser } from "../entities.js"; -const JWT_SECRET = new TextEncoder().encode("emulate-google-jwt-secret"); +async function generateSigningKeys() { + const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true }); + const publicJwk = await exportJWK(publicKey); + const kid = await calculateJwkThumbprint(publicJwk); + return { privateKey, publicJwk: { ...publicJwk, kid, use: "sig", alg: "RS256" }, kid }; +} + +// All instances served by this process advertise the same public key. Generate +// it on the first OIDC request, never during module loading. +let signingKeys: ReturnType | undefined; +const keys = () => (signingKeys ??= generateSigningKeys()); type PendingCode = { email: string; @@ -67,6 +77,7 @@ async function createIdToken( nonce: string | null, baseUrl: string, ): Promise { + const { privateKey, kid } = await keys(); const builder = new SignJWT({ sub: user.uid, email: user.email, @@ -79,13 +90,13 @@ async function createIdToken( ...(user.hd ? { hd: user.hd } : {}), ...(nonce ? { nonce } : {}), }) - .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setProtectedHeader({ alg: "RS256", kid, typ: "JWT" }) .setIssuer(baseUrl) .setAudience(clientId) .setIssuedAt() .setExpirationTime("1h"); - return builder.sign(JWT_SECRET); + return builder.sign(privateKey); } export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { @@ -103,7 +114,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 +132,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({ keys: [(await keys()).publicJwk] }); }); // Google API Discovery document, pointed at this instance. diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 841b31bae..7a4cd241f 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -64,6 +64,7 @@ Global catalog: Available services include vercel, github, gitlab, google, slack, apple, microsoft, okta, aws, resend, stripe, mongoatlas, clerk, spotify, x, workos, autumn, context, posthog, and mcp. + Google OIDC tokens use RS256; verify them with the instance /oauth2/v3/certs. MCP OAuth compliance scenarios are configured under mcp.oauth in seed data; see the MCP manifest seed schema for issuer, resource, DCR, and token-auth knobs. Microsoft Graph includes OneDrive file content upload/download routes under diff --git a/skills/google/SKILL.md b/skills/google/SKILL.md index 1d8d5e3e6..937866caa 100644 --- a/skills/google/SKILL.md +++ b/skills/google/SKILL.md @@ -6,6 +6,8 @@ allowed-tools: Bash(npx emulate:*), Bash(emulate:*), Bash(curl:*) # Google OAuth 2.0 / OIDC + Gmail, Calendar & Drive Emulator +Google ID tokens use RS256 signatures. Verify them with the public keys at `/oauth2/v3/certs`, the instance issuer, your client ID, and the request nonce. Signing keys last for the server process; restarting it invalidates earlier tokens. + 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. ## Start