Skip to content

Commit d30cc2e

Browse files
feat(auth): support multiple Google hosted domains (#605)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbdbfa1 commit d30cc2e

4 files changed

Lines changed: 208 additions & 5 deletions

File tree

src/lib/server/auth/google.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
// Mutable env object: google.ts reads `env.GOOGLE_HOSTED_DOMAIN` at call time,
4+
// so mutating this between tests is enough — no need to re-import the module.
5+
const env: Record<string, string | undefined> = {};
6+
vi.mock('$env/dynamic/private', () => ({ env }));
7+
8+
const mockVerifyIdToken = vi.fn();
9+
const mockGenerateAuthUrl = vi.fn();
10+
const mockGetToken = vi.fn();
11+
vi.mock('google-auth-library', () => ({
12+
CodeChallengeMethod: { S256: 'S256' },
13+
OAuth2Client: vi.fn().mockImplementation(() => ({
14+
verifyIdToken: mockVerifyIdToken,
15+
generateAuthUrl: mockGenerateAuthUrl,
16+
getToken: mockGetToken,
17+
})),
18+
}));
19+
20+
function payloadWithHd(hd: string | undefined) {
21+
return {
22+
getPayload: () => ({ sub: 'user-id', email: 'a@b.com', name: 'Test', picture: null, hd }),
23+
};
24+
}
25+
26+
beforeEach(() => {
27+
delete env.GOOGLE_HOSTED_DOMAIN;
28+
});
29+
30+
afterEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
describe('verifyIdToken hosted-domain enforcement', () => {
35+
test('accepts a token whose hd matches the single configured domain', async () => {
36+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg';
37+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('moe.edu.sg'));
38+
39+
const { verifyIdToken } = await import('./google.js');
40+
await expect(verifyIdToken('token')).resolves.toMatchObject({ email: 'a@b.com' });
41+
});
42+
43+
test('rejects a token whose hd does not match the single configured domain', async () => {
44+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg';
45+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('gmail.com'));
46+
47+
const { verifyIdToken, HostedDomainMismatchError, InvalidIdTokenError } = await import(
48+
'./google.js'
49+
);
50+
// A subclass of InvalidIdTokenError so existing catch-all handling still applies,
51+
// but distinguishable so the callback can show a domain-specific message.
52+
await expect(verifyIdToken('token')).rejects.toBeInstanceOf(HostedDomainMismatchError);
53+
await expect(verifyIdToken('token')).rejects.toBeInstanceOf(InvalidIdTokenError);
54+
});
55+
56+
test('accepts a token whose hd matches any of multiple configured domains', async () => {
57+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg,hci.edu.sg';
58+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('hci.edu.sg'));
59+
60+
const { verifyIdToken } = await import('./google.js');
61+
await expect(verifyIdToken('token')).resolves.toMatchObject({ email: 'a@b.com' });
62+
});
63+
64+
test('rejects a token whose hd is in none of the multiple configured domains', async () => {
65+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg,hci.edu.sg';
66+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('nus.edu.sg'));
67+
68+
const { verifyIdToken, HostedDomainMismatchError } = await import('./google.js');
69+
await expect(verifyIdToken('token')).rejects.toBeInstanceOf(HostedDomainMismatchError);
70+
});
71+
72+
test('tolerates whitespace around comma-separated domains', async () => {
73+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg , hci.edu.sg';
74+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('hci.edu.sg'));
75+
76+
const { verifyIdToken } = await import('./google.js');
77+
await expect(verifyIdToken('token')).resolves.toMatchObject({ email: 'a@b.com' });
78+
});
79+
80+
test('matches domains case-insensitively', async () => {
81+
env.GOOGLE_HOSTED_DOMAIN = 'MOE.edu.sg';
82+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('moe.edu.sg'));
83+
84+
const { verifyIdToken } = await import('./google.js');
85+
await expect(verifyIdToken('token')).resolves.toMatchObject({ email: 'a@b.com' });
86+
});
87+
88+
test('rejects a token with no hd claim when a domain is configured', async () => {
89+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg';
90+
mockVerifyIdToken.mockResolvedValue(payloadWithHd(undefined));
91+
92+
const { verifyIdToken, HostedDomainMismatchError } = await import('./google.js');
93+
await expect(verifyIdToken('token')).rejects.toBeInstanceOf(HostedDomainMismatchError);
94+
});
95+
96+
test('accepts any hd when no domain is configured', async () => {
97+
mockVerifyIdToken.mockResolvedValue(payloadWithHd('gmail.com'));
98+
99+
const { verifyIdToken } = await import('./google.js');
100+
await expect(verifyIdToken('token')).resolves.toMatchObject({ email: 'a@b.com' });
101+
});
102+
});
103+
104+
describe('generateAuthURL hosted-domain hint', () => {
105+
function hdArg() {
106+
return mockGenerateAuthUrl.mock.calls[0][0].hd;
107+
}
108+
109+
async function callGenerate() {
110+
const { generateAuthURL } = await import('./google.js');
111+
generateAuthURL({ origin: 'https://app.example.com', state: 's', codeVerifier: 'v' });
112+
}
113+
114+
test('passes the single configured domain as the hd hint', async () => {
115+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg';
116+
await callGenerate();
117+
expect(hdArg()).toBe('moe.edu.sg');
118+
});
119+
120+
test('passes the wildcard hint when multiple domains are configured', async () => {
121+
env.GOOGLE_HOSTED_DOMAIN = 'moe.edu.sg,hci.edu.sg';
122+
await callGenerate();
123+
expect(hdArg()).toBe('*');
124+
});
125+
126+
test('passes no hd hint when no domain is configured', async () => {
127+
await callGenerate();
128+
expect(hdArg()).toBeUndefined();
129+
});
130+
});

src/lib/server/auth/google.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export interface GoogleProfile {
2828
*/
2929
const GOOGLE_REDIRECT_URL_PATH = '/auth/google/callback';
3030

31+
/**
32+
* Parses `GOOGLE_HOSTED_DOMAIN` into the list of allowed Google Workspace
33+
* domains. Accepts a single domain or a comma-separated list; whitespace around
34+
* entries is ignored and entries are lower-cased so matching is
35+
* case-insensitive (Google's `hd` claim is always lower-case, so a config like
36+
* `MOE.edu.sg` would otherwise silently reject every user). Returns an empty
37+
* array when unset, meaning no hosted-domain restriction is enforced.
38+
*/
39+
function getAllowedHostedDomains(): string[] {
40+
return (env.GOOGLE_HOSTED_DOMAIN ?? '')
41+
.split(',')
42+
.map((domain) => domain.trim().toLowerCase())
43+
.filter((domain) => domain.length > 0);
44+
}
45+
3146
function getOAuth2Client() {
3247
return new OAuth2Client({
3348
client_id: env.GOOGLE_CLIENT_ID,
@@ -67,6 +82,14 @@ export function generateAuthURL({
6782
}): string {
6883
const client = getOAuth2Client();
6984

85+
// `hd` is only an account-chooser hint and accepts a single value. With one
86+
// configured domain we pass it directly; with several we fall back to `*`,
87+
// which limits the chooser to managed Workspace accounts. The actual
88+
// restriction is enforced in `verifyIdToken`.
89+
const allowedDomains = getAllowedHostedDomains();
90+
const hd =
91+
allowedDomains.length === 0 ? undefined : allowedDomains.length === 1 ? allowedDomains[0] : '*';
92+
7093
return client.generateAuthUrl({
7194
redirect_uri: origin + GOOGLE_REDIRECT_URL_PATH,
7295
scope: [
@@ -76,7 +99,7 @@ export function generateAuthURL({
7699
state,
77100
code_challenge: createHash('sha256').update(codeVerifier).digest('base64url'),
78101
code_challenge_method: CodeChallengeMethod.S256,
79-
hd: env.GOOGLE_HOSTED_DOMAIN,
102+
hd,
80103
prompt,
81104
});
82105
}
@@ -127,11 +150,20 @@ export async function exchangeCodeForIdToken({
127150
* Verifies the Google ID token and extracts the profile information.
128151
*
129152
* Throws `InvalidIdTokenError` when the token cannot be trusted — bad
130-
* signature, expired, missing claims, or hosted-domain mismatch.
153+
* signature, expired, or missing claims — and `HostedDomainMismatchError`
154+
* (a subclass) when the token's hosted domain is not allow-listed.
155+
*
156+
* The hosted-domain restriction is enforced on the Google-verified `hd`
157+
* (Workspace) claim, not on the email address. `hd` is the account's Workspace
158+
* primary domain, so any account belonging to an allow-listed Workspace is
159+
* admitted regardless of its email's literal domain (e.g. a secondary or alias
160+
* domain). This matches the `GOOGLE_HOSTED_DOMAIN` semantics — it lists
161+
* Workspace domains, not email domains — and is Google's recommended gate.
131162
*
132163
* @param idToken - The Google ID token to verify.
133164
* @returns The Google profile.
134165
* @throws InvalidIdTokenError
166+
* @throws HostedDomainMismatchError
135167
*/
136168
export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
137169
const client = getOAuth2Client();
@@ -153,9 +185,12 @@ export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
153185
throw new InvalidIdTokenError(`Google ID token missing claim: ${missing}`);
154186
}
155187

156-
if (env.GOOGLE_HOSTED_DOMAIN && payload.hd !== env.GOOGLE_HOSTED_DOMAIN) {
157-
throw new InvalidIdTokenError(
158-
'Google ID token hosted domain does not match the configured hosted domain',
188+
// Gate on the verified `hd` (Workspace) claim, not the email domain — see the
189+
// function docstring for why.
190+
const allowedDomains = getAllowedHostedDomains();
191+
if (allowedDomains.length > 0 && (!payload.hd || !allowedDomains.includes(payload.hd))) {
192+
throw new HostedDomainMismatchError(
193+
'Google ID token hosted domain does not match a configured hosted domain',
159194
);
160195
}
161196

@@ -171,3 +206,11 @@ export class InvalidIdTokenError extends Error {
171206
this.name = this.constructor.name;
172207
}
173208
}
209+
210+
/**
211+
* Thrown when the token is otherwise valid but its hosted domain is not one of
212+
* the domains configured in `GOOGLE_HOSTED_DOMAIN`. A subclass of
213+
* `InvalidIdTokenError` so existing catch-all handling still rejects it, while
214+
* callers that care can distinguish it to surface a domain-specific message.
215+
*/
216+
export class HostedDomainMismatchError extends InvalidIdTokenError {}

src/routes/(main)/auth/google/callback/+server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HOME_PATH } from '$lib/helpers';
44
import {
55
exchangeCodeForIdToken,
66
type GoogleProfile,
7+
HostedDomainMismatchError,
78
learnerAuth,
89
verifyIdToken,
910
} from '$lib/server/auth/index.js';
@@ -72,6 +73,10 @@ export const GET: RequestHandler = async (event) => {
7273
try {
7374
profile = await verifyIdToken(idToken);
7475
} catch (err) {
76+
if (err instanceof HostedDomainMismatchError) {
77+
logger.warn({ err }, 'Rejected sign-in from a non-allowed hosted domain');
78+
return redirect(302, '/login?error=domain_not_allowed');
79+
}
7580
logger.error({ err }, 'Failed to verify ID token');
7681
return redirect(302, '/login?error=oauth2_callback_failed');
7782
}

src/routes/(main)/login/+page.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22
import { page } from '$app/state';
33
import { LinkButton } from '$lib/components/Button/index.js';
44
import { HOME_PATH } from '$lib/helpers/index.js';
5+
6+
const ERROR_MESSAGES: Record<string, string> = {
7+
domain_not_allowed:
8+
'You can only sign in with an approved organisation account. Please try again with your work email.',
9+
};
10+
const GENERIC_ERROR_MESSAGE = 'Something went wrong while signing you in. Please try again.';
11+
12+
// Map the `error` query param to a user-facing message. Known codes get a
13+
// tailored message; any other non-empty code falls back to the generic one.
14+
const errorMessage = $derived.by(() => {
15+
const error = page.url.searchParams.get('error');
16+
if (!error) {
17+
return null;
18+
}
19+
return ERROR_MESSAGES[error] ?? GENERIC_ERROR_MESSAGE;
20+
});
521
</script>
622

723
<main class="flex min-h-svh flex-col">
@@ -22,6 +38,15 @@
2238
<span class="font-logo text-center text-3xl">Small Bites.<br />Big Growth.</span>
2339

2440
<div class="flex flex-col items-center gap-y-4">
41+
{#if errorMessage}
42+
<div
43+
role="alert"
44+
class="w-full max-w-sm rounded-2xl bg-red-50 px-4 py-3 text-center text-sm text-red-700"
45+
>
46+
{errorMessage}
47+
</div>
48+
{/if}
49+
2550
<LinkButton
2651
href={`/auth/google?return_to=${encodeURIComponent(page.url.searchParams.get('return_to') || HOME_PATH)}`}
2752
data-sveltekit-reload

0 commit comments

Comments
 (0)