import crypto from 'node:crypto'; import { authorizationUrl, codeChallenge, newAttempt, verifiedIdentity } from '../../src/google/oauth'; import type { GoogleConfig } from '../../src/google/config'; const CONFIG: GoogleConfig = { clientId: 'id.apps.googleusercontent.com', clientSecret: 'shh', redirectUri: 'https://redefined-designs.com/api/auth/google/callback', enabled: true }; const NONCE = 'the-nonce-for-this-attempt'; /** An id token with the given claims. Unsigned, because nothing here reads a signature. */ function idToken(claims: Record): string { const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); return `${part({ alg: 'RS256' })}.${part(claims)}.not-a-signature`; } const VALID = { iss: 'https://accounts.google.com', aud: CONFIG.clientId, exp: Math.floor(Date.now() / 1000) + 3600, sub: '1234567890', nonce: NONCE, email: 'Customer@Example.com', email_verified: true, given_name: 'Test', family_name: 'Customer' }; /** VALID minus one claim, for the tests about a claim being absent. */ function without(claim: keyof typeof VALID): Record { const copy: Record = { ...VALID }; delete copy[claim]; return copy; } describe('authorizationUrl', () => { const attempt = { state: 'st', nonce: 'no', codeVerifier: 'ver' }; it('sends the customer to Google with the code flow and PKCE', () => { const url = new URL(authorizationUrl(CONFIG, attempt)); expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth'); expect(url.searchParams.get('response_type')).toBe('code'); expect(url.searchParams.get('client_id')).toBe(CONFIG.clientId); expect(url.searchParams.get('redirect_uri')).toBe(CONFIG.redirectUri); expect(url.searchParams.get('state')).toBe('st'); expect(url.searchParams.get('nonce')).toBe('no'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); expect(url.searchParams.get('code_challenge')).toBe(codeChallenge('ver')); }); it('asks for exactly the three non-sensitive scopes', () => { // Anything beyond these turns publishing into a verification review with a // video walkthrough and a wait measured in weeks. const url = new URL(authorizationUrl(CONFIG, attempt)); expect(url.searchParams.get('scope')?.split(' ').sort()).toEqual(['email', 'openid', 'profile']); }); it('never asks for offline access', () => { // A refresh token would be a long-lived credential with nothing to spend it // on. Google issues one only when asked, so the check is that we do not ask. const url = new URL(authorizationUrl(CONFIG, attempt)); expect(url.searchParams.get('access_type')).toBeNull(); expect(url.searchParams.get('prompt')).toBeNull(); }); it('never puts the client secret in a URL the browser follows', () => { expect(authorizationUrl(CONFIG, attempt)).not.toContain(CONFIG.clientSecret); }); }); describe('newAttempt', () => { it('mints three different secrets', () => { // Three rather than one reused: they are checked by different parties at // different moments, and a single value would mean anything learning it // from one check satisfies the others. const { state, nonce, codeVerifier } = newAttempt(); expect(new Set([state, nonce, codeVerifier]).size).toBe(3); }); it('does not repeat itself', () => { expect(newAttempt().state).not.toBe(newAttempt().state); }); }); describe('codeChallenge', () => { it('is the base64url SHA-256 of the verifier, which is what S256 means', () => { const verifier = 'a-verifier'; expect(codeChallenge(verifier)).toBe( crypto.createHash('sha256').update(verifier).digest('base64url') ); }); }); /** * The security of the whole flow lives here. * * No signature is verified, because the token arrives on a direct TLS * connection to Google's token endpoint — the case OpenID Connect explicitly * permits skipping it. That makes every one of these claim checks load-bearing * rather than belt-and-braces, so each has a test naming what accepting it * blindly would allow. */ describe('verifiedIdentity', () => { const expected = { clientId: CONFIG.clientId, nonce: NONCE }; it('accepts a well-formed token and reports who signed in', () => { const identity = verifiedIdentity(idToken(VALID), expected); expect(identity.sub).toBe('1234567890'); expect(identity.emailVerified).toBe(true); expect(identity.firstName).toBe('Test'); expect(identity.lastName).toBe('Customer'); }); it('normalises the email the way registration does', () => { // A stricter comparison than registration's would silently fail to match an // existing customer and produce a duplicate account instead (#343). expect(verifiedIdentity(idToken(VALID), expected).email).toBe('customer@example.com'); }); it('accepts both spellings of the issuer, because Google sends both', () => { // Accepting only one produces sign-ins that fail for some customers and not // others, which is about the least diagnosable failure this flow can have. for (const iss of ['https://accounts.google.com', 'accounts.google.com']) { expect(verifiedIdentity(idToken({ ...VALID, iss }), expected).sub).toBe('1234567890'); } }); it('refuses an issuer it was not told to trust', () => { expect(() => verifiedIdentity(idToken({ ...VALID, iss: 'https://evil.test' }), expected)).toThrow( /unexpected issuer/ ); }); it('refuses a token minted for a different application', () => { // Without this, a token obtained by any other Google app could be replayed // here and would sign somebody in. expect(() => verifiedIdentity(idToken({ ...VALID, aud: 'someone-else.apps.googleusercontent.com' }), expected) ).toThrow(/different client/); }); it('refuses an expired token', () => { const expired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 3600 }; expect(() => verifiedIdentity(idToken(expired), expected)).toThrow(/expired/); }); it('allows a minute of clock skew, so a healthy host does not refuse valid tokens', () => { const justExpired = { ...VALID, exp: Math.floor(Date.now() / 1000) - 5 }; expect(verifiedIdentity(idToken(justExpired), expected).sub).toBe('1234567890'); }); it('refuses a token with no expiry at all', () => { expect(() => verifiedIdentity(idToken(without('exp')), expected)).toThrow(/no expiry/); }); it('refuses a token from a different sign-in attempt', () => { // This is what stops a token captured from one attempt being replayed into // another, which is the whole reason the nonce exists. expect(() => verifiedIdentity(idToken({ ...VALID, nonce: 'someone-elses' }), expected)).toThrow( /different sign-in attempt/ ); }); it('refuses a token carrying no nonce', () => { expect(() => verifiedIdentity(idToken(without('nonce')), expected)).toThrow( /different sign-in attempt/ ); }); it('refuses a token with no subject, which is the identity itself', () => { expect(() => verifiedIdentity(idToken(without('sub')), expected)).toThrow(/no subject/); }); it('refuses a token with no email', () => { expect(() => verifiedIdentity(idToken(without('email')), expected)).toThrow(/no email/); }); describe('email_verified', () => { it('is true only for the boolean, never a truthy string', () => { // The linking policy turns entirely on this flag (#343). Treating the // string "false" as verified is exactly the mistake that would make // auto-linking an account-takeover path. expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'false' }), expected).emailVerified) .toBe(false); expect(verifiedIdentity(idToken({ ...VALID, email_verified: 'true' }), expected).emailVerified) .toBe(false); }); it('is false when the claim is missing', () => { expect(verifiedIdentity(idToken(without('email_verified')), expected).emailVerified).toBe(false); }); }); it('tolerates a profile with no names, because Google may send none', () => { const anonymous = { ...without('given_name') }; delete anonymous.family_name; const identity = verifiedIdentity(idToken(anonymous), expected); expect(identity.firstName).toBeNull(); expect(identity.lastName).toBeNull(); }); it.each([ ['not a JWT', 'nonsense'], ['a JWT whose payload is not JSON', 'aGVhZGVy.bm90LWpzb24.sig'] ])('refuses %s', (_label, token) => { expect(() => verifiedIdentity(token, expected)).toThrow(); }); });