diff --git a/apps/web/app/configuration/page.mdx b/apps/web/app/configuration/page.mdx index 250be804..44ba5f70 100644 --- a/apps/web/app/configuration/page.mdx +++ b/apps/web/app/configuration/page.mdx @@ -127,6 +127,7 @@ github: ``` **Endpoints:** + - `GET /app` - get authenticated app (JWT required) - `GET /app/installations` - list app installations - `GET /app/installations/:id` - get installation @@ -149,6 +150,16 @@ google: client_secret: GOCSPX-secret redirect_uris: - http://localhost:3000/api/auth/callback/google + # Optional. Defaults to HS256 with an internal secret. Set algorithm to RS256 + # to sign id_tokens with an RSA key pair and publish JWKS at /oauth2/v3/certs. + id_token: + algorithm: RS256 + ## Accepts an inline PKCS8 PEM or a path to a PEM file (resolved from the + ## current working directory). Omit it to auto-generate a key at startup. + # private_key: | + # -----BEGIN PRIVATE KEY----- + # ...your PKCS8 PEM... + # -----END PRIVATE KEY----- labels: - id: Label_ops user_email: testuser@example.com diff --git a/emulate.config.example.yaml b/emulate.config.example.yaml index e4ae82d6..2789b607 100644 --- a/emulate.config.example.yaml +++ b/emulate.config.example.yaml @@ -89,6 +89,8 @@ google: name: Code App (Google) redirect_uris: - http://localhost:3000/api/auth/callback/google + id_token: + algorithm: RS256 labels: - id: Label_ops user_email: testuser@gmail.com diff --git a/packages/@emulators/google/src/__tests__/google.test.ts b/packages/@emulators/google/src/__tests__/google.test.ts index 31218c67..febd0536 100644 --- a/packages/@emulators/google/src/__tests__/google.test.ts +++ b/packages/@emulators/google/src/__tests__/google.test.ts @@ -1,5 +1,10 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; import { Store, WebhookDispatcher, @@ -10,6 +15,7 @@ import { } from "@emulators/core"; import { googlePlugin, seedFromConfig } from "../index.js"; import { buildRawMessage } from "../helpers.js"; +import { resetIdTokenSigning } from "../routes/oauth.js"; const base = "http://localhost:4000"; @@ -1061,3 +1067,182 @@ describe("Google plugin integration", () => { expect(Buffer.from(await uploadedMediaRes.arrayBuffer()).toString("utf8")).toBe(uploadedContent); }); }); + +describe("Google id_token signing", () => { + afterEach(() => { + resetIdTokenSigning(); + }); + + async function runOauthFlow(app: Hono) { + const authorizeRes = await app.request(`${base}/o/oauth2/v2/auth/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + email: "testuser@example.com", + redirect_uri: "http://localhost:3000/api/auth/callback/google", + scope: "openid email profile", + client_id: "emu_google_client_id", + nonce: "test-nonce", + }).toString(), + }); + const code = new URL(authorizeRes.headers.get("Location")!).searchParams.get("code")!; + const tokenRes = await app.request(`${base}/oauth2/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + 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", + }).toString(), + }); + return (await tokenRes.json()) as { id_token: string }; + } + + it("defaults to HS256 and exposes an empty JWKS", async () => { + const { app } = createTestApp(); + + const discoveryRes = await app.request(`${base}/.well-known/openid-configuration`); + const discovery = (await discoveryRes.json()) as { id_token_signing_alg_values_supported: string[] }; + expect(discovery.id_token_signing_alg_values_supported).toEqual(["HS256"]); + + const certsRes = await app.request(`${base}/oauth2/v3/certs`); + expect(await certsRes.json()).toEqual({ keys: [] }); + + const { id_token } = await runOauthFlow(app); + expect(decodeProtectedHeader(id_token).alg).toBe("HS256"); + expect(decodeJwt(id_token).nonce).toBe("test-nonce"); + }); + + it("signs id_token with RS256 using a provided private key and publishes JWKS", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + + const app = new Hono(); + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokenMap)); + googlePlugin.register(app as any, store, webhooks, base, tokenMap); + seedFromConfig(store, base, { + users: [{ email: "testuser@example.com", name: "Test User" }], + oauth_clients: [ + { + client_id: "emu_google_client_id", + client_secret: "emu_google_client_secret", + name: "RS256 App", + redirect_uris: ["http://localhost:3000/api/auth/callback/google"], + }, + ], + id_token: { algorithm: "RS256", private_key: privateKeyPem }, + }); + + const discoveryRes = await app.request(`${base}/.well-known/openid-configuration`); + const discovery = (await discoveryRes.json()) as { id_token_signing_alg_values_supported: string[] }; + expect(discovery.id_token_signing_alg_values_supported).toEqual(["RS256"]); + + const certsRes = await app.request(`${base}/oauth2/v3/certs`); + const certs = (await certsRes.json()) as { keys: Array> }; + expect(certs.keys).toHaveLength(1); + const jwk = certs.keys[0]; + expect(jwk.kty).toBe("RSA"); + expect(jwk.alg).toBe("RS256"); + expect(jwk.use).toBe("sig"); + expect(jwk.kid).toBe("emulate-google-1"); + expect(jwk.n).toBeDefined(); + expect(jwk.e).toBe("AQAB"); + + const { id_token } = await runOauthFlow(app); + const header = decodeProtectedHeader(id_token); + expect(header.alg).toBe("RS256"); + expect(header.kid).toBe("emulate-google-1"); + + const verificationKey = await importJWK(jwk, "RS256"); + const { payload } = await jwtVerify(id_token, verificationKey, { issuer: base, audience: "emu_google_client_id" }); + expect(payload.email).toBe("testuser@example.com"); + expect(payload.nonce).toBe("test-nonce"); + }); + + it("loads the RS256 private key from a file path", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const dir = mkdtempSync(join(tmpdir(), "emulate-google-key-")); + const keyPath = join(dir, "rs256.pem"); + writeFileSync(keyPath, pem, "utf-8"); + + try { + const app = new Hono(); + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokenMap)); + googlePlugin.register(app as any, store, webhooks, base, tokenMap); + seedFromConfig(store, base, { + users: [{ email: "testuser@example.com", name: "Test User" }], + oauth_clients: [ + { + client_id: "emu_google_client_id", + client_secret: "emu_google_client_secret", + name: "RS256 File", + redirect_uris: ["http://localhost:3000/api/auth/callback/google"], + }, + ], + id_token: { algorithm: "RS256", private_key: keyPath }, + }); + + const certsRes = await app.request(`${base}/oauth2/v3/certs`); + const certs = (await certsRes.json()) as { keys: Array> }; + expect(certs.keys).toHaveLength(1); + + const { id_token } = await runOauthFlow(app); + expect(decodeProtectedHeader(id_token).alg).toBe("RS256"); + + const verificationKey = await importJWK(certs.keys[0], "RS256"); + await expect( + jwtVerify(id_token, verificationKey, { issuer: base, audience: "emu_google_client_id" }), + ).resolves.toBeDefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("auto-generates an RSA key pair when algorithm is RS256 without a private key", async () => { + const app = new Hono(); + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokenMap)); + googlePlugin.register(app as any, store, webhooks, base, tokenMap); + seedFromConfig(store, base, { + users: [{ email: "testuser@example.com", name: "Test User" }], + oauth_clients: [ + { + client_id: "emu_google_client_id", + client_secret: "emu_google_client_secret", + name: "RS256 Auto", + redirect_uris: ["http://localhost:3000/api/auth/callback/google"], + }, + ], + id_token: { algorithm: "RS256" }, + }); + + const certsRes = await app.request(`${base}/oauth2/v3/certs`); + const certs = (await certsRes.json()) as { keys: Array> }; + expect(certs.keys).toHaveLength(1); + expect(certs.keys[0].kty).toBe("RSA"); + + const { id_token } = await runOauthFlow(app); + expect(decodeProtectedHeader(id_token).alg).toBe("RS256"); + + const verificationKey = await importJWK(certs.keys[0], "RS256"); + const { payload } = await jwtVerify(id_token, verificationKey, { issuer: base, audience: "emu_google_client_id" }); + expect(payload.email).toBe("testuser@example.com"); + }); +}); diff --git a/packages/@emulators/google/src/index.ts b/packages/@emulators/google/src/index.ts index 465f9577..3c915b09 100644 --- a/packages/@emulators/google/src/index.ts +++ b/packages/@emulators/google/src/index.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; import type { AppEnv, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; import type { Hono } from "hono"; import { @@ -16,7 +18,7 @@ import { driveRoutes } from "./routes/drive.js"; import { historyRoutes } from "./routes/history.js"; import { labelRoutes } from "./routes/labels.js"; import { messageRoutes } from "./routes/messages.js"; -import { oauthRoutes } from "./routes/oauth.js"; +import { configureIdTokenSigning, oauthRoutes, type IdTokenAlgorithm } from "./routes/oauth.js"; import { settingsRoutes } from "./routes/settings.js"; import { threadRoutes } from "./routes/threads.js"; import { getGoogleStore } from "./store.js"; @@ -120,6 +122,10 @@ export interface GoogleSeedConfig { name?: string; redirect_uris: string[]; }>; + id_token?: { + algorithm?: IdTokenAlgorithm; + private_key?: string; + }; labels?: GoogleSeedLabel[]; messages?: GoogleSeedMessage[]; calendars?: GoogleSeedCalendar[]; @@ -275,9 +281,23 @@ function seedDefaults(store: Store, _baseUrl: string): void { ); } +function resolveIdTokenPrivateKey(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (trimmed.includes("-----BEGIN")) return trimmed; + return readFileSync(resolvePath(trimmed), "utf-8"); +} + export function seedFromConfig(store: Store, _baseUrl: string, config: GoogleSeedConfig): void { const gs = getGoogleStore(store); + if (config.id_token) { + configureIdTokenSigning({ + algorithm: config.id_token.algorithm, + privateKey: resolveIdTokenPrivateKey(config.id_token.private_key), + }); + } + if (config.users) { for (const user of config.users) { const existing = gs.users.findOneBy("email", user.email); diff --git a/packages/@emulators/google/src/routes/oauth.ts b/packages/@emulators/google/src/routes/oauth.ts index 9da2c3a1..41903f5a 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 { createHash, createPrivateKey, createPublicKey, randomBytes } from "crypto"; +import { JWTHeaderParameters, SignJWT, exportJWK, generateKeyPair } from "jose"; import type { RouteContext } from "@emulators/core"; import { escapeHtml, @@ -18,6 +18,44 @@ import type { GoogleUser } from "../entities.js"; const JWT_SECRET = new TextEncoder().encode("emulate-google-jwt-secret"); +const KID = "emulate-google-1"; + +type SigningKey = Awaited>["privateKey"]; +type RsaKeyPair = { privateKey: SigningKey; publicKey: SigningKey }; + +export type IdTokenAlgorithm = "HS256" | "RS256"; + +let signingAlgorithm: IdTokenAlgorithm = "HS256"; +let rsaKeyPairPromise: Promise | null = null; + +function getRsaKeyPair(): Promise { + if (!rsaKeyPairPromise) { + rsaKeyPairPromise = generateKeyPair("RS256"); + } + return rsaKeyPairPromise; +} + +export function configureIdTokenSigning(options: { algorithm?: IdTokenAlgorithm; privateKey?: string }): void { + if (options.privateKey) { + const privateKey = createPrivateKey(options.privateKey); + const publicKey = createPublicKey(privateKey); + rsaKeyPairPromise = Promise.resolve({ + privateKey: privateKey as unknown as SigningKey, + publicKey: publicKey as unknown as SigningKey, + }); + signingAlgorithm = options.algorithm ?? "RS256"; + return; + } + if (options.algorithm) { + signingAlgorithm = options.algorithm; + } +} + +export function resetIdTokenSigning(): void { + signingAlgorithm = "HS256"; + rsaKeyPairPromise = null; +} + type PendingCode = { email: string; scope: string; @@ -67,7 +105,7 @@ async function createIdToken( nonce: string | null, baseUrl: string, ): Promise { - const builder = new SignJWT({ + const claims = { sub: user.uid, email: user.email, email_verified: user.email_verified, @@ -77,14 +115,24 @@ async function createIdToken( picture: user.picture, locale: user.locale, ...(nonce ? { nonce } : {}), - }) - .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + }; + + let header: JWTHeaderParameters = { alg: "HS256", typ: "JWT" }; + let signingKey: Uint8Array | SigningKey = JWT_SECRET; + + if (signingAlgorithm === "RS256") { + const { privateKey } = await getRsaKeyPair(); + header = { alg: "RS256", kid: KID, typ: "JWT" }; + signingKey = privateKey; + } + + return new SignJWT(claims) + .setProtectedHeader(header) .setIssuer(baseUrl) .setAudience(clientId) .setIssuedAt() - .setExpirationTime("1h"); - - return builder.sign(JWT_SECRET); + .setExpirationTime("1h") + .sign(signingKey); } export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { @@ -102,7 +150,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: [signingAlgorithm], scopes_supported: ["openid", "email", "profile"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], claims_supported: ["sub", "email", "email_verified", "name", "given_name", "family_name", "picture", "locale"], @@ -110,10 +158,17 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }); }); - // ---------- JWKS (stub) ---------- + // ---------- JWKS ---------- - app.get("/oauth2/v3/certs", (c) => { - return c.json({ keys: [] }); + app.get("/oauth2/v3/certs", async (c) => { + if (signingAlgorithm !== "RS256") { + return c.json({ keys: [] }); + } + const { publicKey } = await getRsaKeyPair(); + const jwk = await exportJWK(publicKey); + return c.json({ + keys: [{ ...jwk, kid: KID, use: "sig", alg: "RS256" }], + }); }); // ---------- Authorization page ---------- diff --git a/skills/google/SKILL.md b/skills/google/SKILL.md index 2ebceaec..01c337d6 100644 --- a/skills/google/SKILL.md +++ b/skills/google/SKILL.md @@ -190,7 +190,7 @@ curl http://localhost:4002/.well-known/openid-configuration curl http://localhost:4002/oauth2/v3/certs ``` -Returns `{ "keys": [] }`. ID tokens are signed with HS256 using an internal secret. +By default, returns `{ "keys": [] }` and ID tokens are signed with HS256 using an internal secret. To produce RS256-signed ID tokens, set `google.id_token.algorithm: RS256` in the config; JWKS will then publish the active RSA public key. `google.id_token.private_key` accepts either an inline PKCS8 PEM or a path to a PEM file (resolved from the current working directory); if omitted, a key pair is generated at startup. ### Authorization