Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/docs/google/page.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
47 changes: 46 additions & 1 deletion packages/@emulators/google/src/__tests__/google.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 19 additions & 8 deletions packages/@emulators/google/src/routes/oauth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof generateSigningKeys> | undefined;
const keys = () => (signingKeys ??= generateSigningKeys());

type PendingCode = {
email: string;
Expand Down Expand Up @@ -67,6 +77,7 @@ async function createIdToken(
nonce: string | null,
baseUrl: string,
): Promise<string> {
const { privateKey, kid } = await keys();
const builder = new SignJWT({
sub: user.uid,
email: user.email,
Expand All @@ -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 {
Expand All @@ -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: [
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/emulate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions skills/google/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading