Skip to content

Commit 499ba4f

Browse files
feat(google): add hd claim to ID tokens and userinfo (#73)
Adds support for the Google Workspace `hd` (hosted domain) claim on issued ID tokens and the `/oauth2/v2/userinfo` response, and advertises it in the OIDC discovery document. By default the claim is derived from the user's email domain, matching real Google behavior for Workspace accounts. Consumer domains (`gmail.com`, `googlemail.com`) are treated as non-Workspace and omit the claim. Configs can override the derived value by setting `hd` on a seeded user, or disable the claim by setting it to an empty string. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0740ba7 commit 499ba4f

4 files changed

Lines changed: 73 additions & 2 deletions

File tree

packages/@emulators/google/src/__tests__/google.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import { Hono } from "hono";
3+
import { decodeJwt } from "jose";
34
import {
45
Store,
56
WebhookDispatcher,
@@ -30,7 +31,11 @@ function createTestApp() {
3031
googlePlugin.register(app as any, store, webhooks, base, tokenMap);
3132
googlePlugin.seed?.(store, base);
3233
seedFromConfig(store, base, {
33-
users: [{ email: "testuser@example.com", name: "Test User" }],
34+
users: [
35+
{ email: "testuser@example.com", name: "Test User" },
36+
{ email: "consumer@gmail.com", name: "Consumer User" },
37+
{ email: "workspaceuser@example.com", name: "Workspace User", hd: "override.io" },
38+
],
3439
oauth_clients: [
3540
{
3641
client_id: "emu_google_client_id",
@@ -892,6 +897,42 @@ describe("Google plugin integration", () => {
892897
expect(refreshBody.scope).toBe(tokenBody.scope);
893898
});
894899

900+
it("derives, overrides, and omits the hd claim based on user config", async () => {
901+
async function getIdTokenClaims(email: string) {
902+
const authorize = await formRequest(app, "/o/oauth2/v2/auth/callback", {
903+
email,
904+
redirect_uri: "http://localhost:3000/api/auth/callback/google",
905+
scope: "openid email profile",
906+
client_id: "emu_google_client_id",
907+
});
908+
const code = new URL(authorize.headers.get("Location")!).searchParams.get("code")!;
909+
const tokenRes = await formRequest(app, "/oauth2/token", {
910+
code,
911+
grant_type: "authorization_code",
912+
redirect_uri: "http://localhost:3000/api/auth/callback/google",
913+
client_id: "emu_google_client_id",
914+
client_secret: "emu_google_client_secret",
915+
});
916+
const body = (await tokenRes.json()) as { id_token: string; access_token: string };
917+
return { claims: decodeJwt(body.id_token) as { hd?: string }, accessToken: body.access_token };
918+
}
919+
920+
const derived = await getIdTokenClaims("testuser@example.com");
921+
expect(derived.claims.hd).toBe("example.com");
922+
923+
const overridden = await getIdTokenClaims("workspaceuser@example.com");
924+
expect(overridden.claims.hd).toBe("override.io");
925+
926+
const consumer = await getIdTokenClaims("consumer@gmail.com");
927+
expect(consumer.claims.hd).toBeUndefined();
928+
929+
const userinfoRes = await app.request(`${base}/oauth2/v2/userinfo`, {
930+
headers: { Authorization: `Bearer ${overridden.accessToken}` },
931+
});
932+
expect(userinfoRes.status).toBe(200);
933+
expect(((await userinfoRes.json()) as { hd?: string }).hd).toBe("override.io");
934+
});
935+
895936
it("lists calendar resources, creates events, queries freebusy, and deletes events", async () => {
896937
const calendarListRes = await app.request(`${base}/calendar/v3/users/me/calendarList`, {
897938
headers: authHeaders(),

packages/@emulators/google/src/entities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface GoogleUser extends Entity {
99
picture: string | null;
1010
email_verified: boolean;
1111
locale: string;
12+
hd: string | null;
1213
}
1314

1415
export interface GoogleOAuthClient extends Entity {

packages/@emulators/google/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface GoogleSeedUser {
3232
picture?: string;
3333
locale?: string;
3434
email_verified?: boolean;
35+
hd?: string;
3536
}
3637

3738
export interface GoogleSeedLabel {
@@ -141,6 +142,7 @@ function seedDefaults(store: Store, _baseUrl: string): void {
141142
picture: null,
142143
email_verified: true,
143144
locale: "en",
145+
hd: null,
144146
});
145147
}
146148

@@ -275,6 +277,20 @@ function seedDefaults(store: Store, _baseUrl: string): void {
275277
);
276278
}
277279

280+
const CONSUMER_EMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
281+
282+
function deriveHd(email: string): string | null {
283+
const domain = email.split("@")[1]?.toLowerCase();
284+
if (!domain) return null;
285+
if (CONSUMER_EMAIL_DOMAINS.has(domain)) return null;
286+
return domain;
287+
}
288+
289+
function resolveHd(user: GoogleSeedUser): string | null {
290+
if (user.hd !== undefined) return user.hd || null;
291+
return deriveHd(user.email);
292+
}
293+
278294
export function seedFromConfig(store: Store, _baseUrl: string, config: GoogleSeedConfig): void {
279295
const gs = getGoogleStore(store);
280296

@@ -292,6 +308,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: GoogleSee
292308
picture: user.picture ?? null,
293309
email_verified: user.email_verified ?? true,
294310
locale: user.locale ?? "en",
311+
hd: resolveHd(user),
295312
});
296313
}
297314

packages/@emulators/google/src/routes/oauth.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async function createIdToken(
7676
family_name: user.family_name,
7777
picture: user.picture,
7878
locale: user.locale,
79+
...(user.hd ? { hd: user.hd } : {}),
7980
...(nonce ? { nonce } : {}),
8081
})
8182
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
@@ -105,7 +106,17 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo
105106
id_token_signing_alg_values_supported: ["HS256"],
106107
scopes_supported: ["openid", "email", "profile"],
107108
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
108-
claims_supported: ["sub", "email", "email_verified", "name", "given_name", "family_name", "picture", "locale"],
109+
claims_supported: [
110+
"sub",
111+
"email",
112+
"email_verified",
113+
"name",
114+
"given_name",
115+
"family_name",
116+
"picture",
117+
"locale",
118+
"hd",
119+
],
109120
code_challenge_methods_supported: ["plain", "S256"],
110121
});
111122
});
@@ -377,6 +388,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo
377388
family_name: user.family_name,
378389
picture: user.picture,
379390
locale: user.locale,
391+
...(user.hd ? { hd: user.hd } : {}),
380392
});
381393
});
382394

0 commit comments

Comments
 (0)