Skip to content
Open
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
53 changes: 52 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, createLocalJWKSet, jwtVerify } from "jose";
import {
Store,
WebhookDispatcher,
Expand Down Expand Up @@ -875,10 +875,20 @@ describe("Google plugin integration", () => {
const tokenBody = (await tokenRes.json()) as {
access_token: string;
refresh_token: string;
id_token: string;
scope: string;
};
expect(tokenBody.access_token).toMatch(/^google_/);
expect(tokenBody.refresh_token).toMatch(/^google_refresh_/);
expect(tokenBody.id_token).toBeDefined();

const claims = decodeJwt(tokenBody.id_token);
expect(claims.iss).toBe(base);
expect(claims.aud).toBe("emu_google_client_id");
expect(claims.sub).toBeDefined();
expect(claims.email).toBe("testuser@example.com");
expect(claims.email_verified).toBe(true);
expect(claims.name).toBe("Test User");

const refreshRes = await formRequest(app, "/oauth2/token", {
grant_type: "refresh_token",
Expand All @@ -897,6 +907,47 @@ describe("Google plugin integration", () => {
expect(refreshBody.scope).toBe(tokenBody.scope);
});

it("GET /oauth2/v3/certs returns JWKS with RSA public key", async () => {
const res = await app.request(`${base}/oauth2/v3/certs`);
expect(res.status).toBe(200);
const body = (await res.json()) as { keys: Array<Record<string, unknown>> };
expect(body.keys).toHaveLength(1);
const key = body.keys[0];
expect(key.kty).toBe("RSA");
expect(key.kid).toBe("emulate-google-1");
expect(key.use).toBe("sig");
expect(key.alg).toBe("RS256");
});

it("id_token signature can be verified against the JWKS endpoint", async () => {
const authorizeRes = 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",
});
const code = new URL(authorizeRes.headers.get("Location")!).searchParams.get("code")!;

const tokenRes = 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",
});
const { id_token } = (await tokenRes.json()) as { id_token: string };

const certsRes = await app.request(`${base}/oauth2/v3/certs`);
const jwksJson = (await certsRes.json()) as { keys: object[] };
const JWKS = createLocalJWKSet(jwksJson);

const { payload } = await jwtVerify(id_token, JWKS, {
issuer: base,
audience: "emu_google_client_id",
});
expect(payload.email).toBe("testuser@example.com");
});

it("derives, overrides, and omits the hd claim based on user config", async () => {
async function getIdTokenClaims(email: string) {
const authorize = await formRequest(app, "/o/oauth2/v2/auth/callback", {
Expand Down
24 changes: 16 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, exportJWK, generateKeyPair } from "jose";
import type { RouteContext } from "@emulators/core";
import {
escapeHtml,
Expand All @@ -16,7 +16,9 @@ import {
import { getGoogleStore } from "../store.js";
import type { GoogleUser } from "../entities.js";

const JWT_SECRET = new TextEncoder().encode("emulate-google-jwt-secret");
// RSA key pair generated at module load for signing id_tokens
const keyPairPromise = generateKeyPair("RS256");
const KID = "emulate-google-1";

type PendingCode = {
email: string;
Expand Down Expand Up @@ -67,6 +69,8 @@ async function createIdToken(
nonce: string | null,
baseUrl: string,
): Promise<string> {
const { privateKey } = await keyPairPromise;

const builder = new SignJWT({
sub: user.uid,
email: user.email,
Expand All @@ -79,13 +83,13 @@ async function createIdToken(
...(user.hd ? { hd: user.hd } : {}),
...(nonce ? { nonce } : {}),
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setProtectedHeader({ alg: "RS256", kid: 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 +107,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 +125,14 @@ 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) => {
const { publicKey } = await keyPairPromise;
const jwk = await exportJWK(publicKey);
return c.json({
keys: [{ ...jwk, kid: KID, use: "sig", alg: "RS256" }],
});
});

// ---------- Authorization page ----------
Expand Down