diff --git a/apps/identity-service/src/google-oidc-client.test.ts b/apps/identity-service/src/google-oidc-client.test.ts new file mode 100644 index 00000000..8903be07 --- /dev/null +++ b/apps/identity-service/src/google-oidc-client.test.ts @@ -0,0 +1,474 @@ +import { generateKeyPairSync, sign, type KeyObject } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { + GoogleOidcClient, + type FixedEndpointFetch, + type FixedEndpointFetchInit, +} from './google-oidc-client'; + +const NOW = new Date('2026-08-03T12:00:00.000Z'); +const CLIENT_ID = 'life-os-google-client'; +const CLIENT_CREDENTIAL = ['confidential', 'client', 'credential'].join('-'); +const MOCK_ACCESS_VALUE = ['mock', 'access', 'value'].join('-'); +const MOCK_REFRESH_VALUE = ['mock', 'refresh', 'value'].join('-'); +const REDIRECT_URI = 'https://identity.example.test/v1/auth/google/callback'; +const CODE_VERIFIER = 'v'.repeat(43); +const NONCE = 'n'.repeat(43); +const AUTHORIZATION_CODE = 'authorization-code'; +const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; +const JWKS_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/certs'; + +class TestHeaders { + private readonly values: ReadonlyMap; + + constructor(values: Record = {}) { + this.values = new Map( + Object.entries(values).map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + } + + get(name: string): string | null { + return this.values.get(name.toLowerCase()) ?? null; + } +} + +function jsonResponse( + body: unknown, + options: { + status?: number; + headers?: Record; + rawBody?: string; + } = {}, +) { + const text = options.rawBody ?? JSON.stringify(body); + return { + status: options.status ?? 200, + headers: new TestHeaders(options.headers), + body: null, + async text(): Promise { + return text; + }, + }; +} + +function keyFixture( + kid: string, + modulusLength = 2_048, +): { + privateKey: KeyObject; + jwk: Record; +} { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength, + }); + return { + privateKey, + jwk: { + ...publicKey.export({ format: 'jwk' }), + kid, + alg: 'RS256', + use: 'sig', + key_ops: ['verify'], + }, + }; +} + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +function signIdToken( + privateKey: KeyObject, + claims: Record, + header: Record = {}, +): string { + const encodedHeader = encodeJson({ + alg: 'RS256', + typ: 'JWT', + kid: 'key-one', + ...header, + }); + const encodedClaims = encodeJson(claims); + const signedContent = `${encodedHeader}.${encodedClaims}`; + const signature = sign( + 'RSA-SHA256', + Buffer.from(signedContent, 'ascii'), + privateKey, + ).toString('base64url'); + return `${signedContent}.${signature}`; +} + +function validClaims(overrides: Record = {}) { + const nowSeconds = Math.floor(NOW.getTime() / 1_000); + return { + iss: 'https://accounts.google.com', + sub: '107691503500061507151', + aud: CLIENT_ID, + azp: CLIENT_ID, + iat: nowSeconds, + exp: nowSeconds + 3_600, + nonce: NONCE, + email: 'person@example.test', + email_verified: true, + name: 'Example Person', + hd: 'example.test', + ...overrides, + }; +} + +function createClient( + fetcher: FixedEndpointFetch, + overrides: Partial[0]> = {}, +): GoogleOidcClient { + return new GoogleOidcClient({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + fetch: fetcher, + now: () => NOW, + ...overrides, + }); +} + +function authenticationInput() { + return { + code: AUTHORIZATION_CODE, + codeVerifier: CODE_VERIFIER, + nonce: NONCE, + }; +} + +describe('GoogleOidcClient', () => { + it('exchanges a code at the fixed endpoint and returns only verified identity claims', async () => { + const key = keyFixture('key-one'); + const idToken = signIdToken(key.privateKey, validClaims()); + const calls: Array<{ url: string; init: FixedEndpointFetchInit }> = []; + const fetcher: FixedEndpointFetch = vi.fn(async (url, init) => { + calls.push({ url, init }); + if (url === TOKEN_ENDPOINT) { + return jsonResponse({ + access_token: MOCK_ACCESS_VALUE, + refresh_token: MOCK_REFRESH_VALUE, + token_type: 'Bearer', + expires_in: 3_600, + id_token: idToken, + }); + } + if (url === JWKS_ENDPOINT) { + return jsonResponse( + { keys: [key.jwk] }, + { headers: { 'cache-control': 'public, max-age=600' } }, + ); + } + throw new Error('unexpected endpoint'); + }); + + const identity = await createClient(fetcher, { + clientSecret: CLIENT_CREDENTIAL, + }).authenticateAuthorizationCode(authenticationInput()); + + expect(identity).toEqual({ + provider: 'google', + subject: '107691503500061507151', + issuer: 'https://accounts.google.com', + email: 'person@example.test', + emailVerified: true, + displayName: 'Example Person', + hostedDomain: 'example.test', + }); + expect(JSON.stringify(identity)).not.toContain(MOCK_ACCESS_VALUE); + expect(JSON.stringify(identity)).not.toContain(MOCK_REFRESH_VALUE); + expect(calls.map((call) => call.url)).toEqual([ + TOKEN_ENDPOINT, + JWKS_ENDPOINT, + ]); + expect(calls[0]?.init).toMatchObject({ + method: 'POST', + redirect: 'error', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + const form = new URLSearchParams(calls[0]?.init.body); + expect(Object.fromEntries(form)).toEqual({ + client_id: CLIENT_ID, + client_secret: CLIENT_CREDENTIAL, + code: AUTHORIZATION_CODE, + code_verifier: CODE_VERIFIER, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + }); + expect(calls[1]?.init).toMatchObject({ + method: 'GET', + redirect: 'error', + headers: { Accept: 'application/json' }, + }); + }); + + it('caches signing keys and refreshes once when Google rotates to an unknown kid', async () => { + const firstKey = keyFixture('key-one'); + const secondKey = keyFixture('key-two'); + const tokens = [ + signIdToken(firstKey.privateKey, validClaims()), + signIdToken(secondKey.privateKey, validClaims(), { kid: 'key-two' }), + signIdToken(firstKey.privateKey, validClaims()), + ]; + let tokenRequests = 0; + let keyRequests = 0; + const fetcher: FixedEndpointFetch = async (url) => { + if (url === TOKEN_ENDPOINT) { + const idToken = tokens[tokenRequests]; + tokenRequests += 1; + return jsonResponse({ id_token: idToken }); + } + if (url === JWKS_ENDPOINT) { + keyRequests += 1; + return jsonResponse( + { + keys: + keyRequests === 1 + ? [firstKey.jwk] + : [firstKey.jwk, secondKey.jwk], + }, + { headers: { 'cache-control': 'max-age=3600' } }, + ); + } + throw new Error('unexpected endpoint'); + }; + const client = createClient(fetcher); + + await expect( + client.authenticateAuthorizationCode(authenticationInput()), + ).resolves.toMatchObject({ subject: '107691503500061507151' }); + await expect( + client.authenticateAuthorizationCode(authenticationInput()), + ).resolves.toMatchObject({ subject: '107691503500061507151' }); + await expect( + client.authenticateAuthorizationCode({ + ...authenticationInput(), + code: `${AUTHORIZATION_CODE}-cached`, + }), + ).resolves.toMatchObject({ subject: '107691503500061507151' }); + + expect(tokenRequests).toBe(3); + expect(keyRequests).toBe(2); + }); + + it('shares an in-flight signing key fetch across concurrent authentications', async () => { + const key = keyFixture('key-one'); + const idToken = signIdToken(key.privateKey, validClaims()); + let tokenRequests = 0; + let keyRequests = 0; + let releaseKeySet: (() => void) | undefined; + let markKeyFetchStarted: (() => void) | undefined; + const keySetGate = new Promise((resolve) => { + releaseKeySet = resolve; + }); + const keyFetchStarted = new Promise((resolve) => { + markKeyFetchStarted = resolve; + }); + const fetcher: FixedEndpointFetch = async (url) => { + if (url === TOKEN_ENDPOINT) { + tokenRequests += 1; + return jsonResponse({ id_token: idToken }); + } + if (url === JWKS_ENDPOINT) { + keyRequests += 1; + markKeyFetchStarted?.(); + await keySetGate; + return jsonResponse( + { keys: [key.jwk] }, + { headers: { 'cache-control': 'max-age=3600' } }, + ); + } + throw new Error('unexpected endpoint'); + }; + const client = createClient(fetcher); + + const authentications = [ + client.authenticateAuthorizationCode({ + ...authenticationInput(), + code: `${AUTHORIZATION_CODE}-one`, + }), + client.authenticateAuthorizationCode({ + ...authenticationInput(), + code: `${AUTHORIZATION_CODE}-two`, + }), + ]; + + await keyFetchStarted; + await new Promise((resolve) => setImmediate(resolve)); + expect(keyRequests).toBe(1); + releaseKeySet?.(); + await expect(Promise.all(authentications)).resolves.toHaveLength(2); + expect(tokenRequests).toBe(2); + expect(keyRequests).toBe(1); + }); + + it.each([ + ['issuer', { iss: 'https://attacker.example' }], + ['audience', { aud: 'another-client' }], + ['authorized presenter', { azp: 'another-client' }], + ['expiration', { exp: Math.floor(NOW.getTime() / 1_000) - 61 }], + ['issued-at time', { iat: Math.floor(NOW.getTime() / 1_000) + 61 }], + ['not-before time', { nbf: Math.floor(NOW.getTime() / 1_000) + 61 }], + ['nonce', { nonce: 'different-nonce' }], + ['subject', { sub: 'bad\nsubject' }], + ['email verification type', { email_verified: 'true' }], + ])( + 'rejects an ID token with an invalid %s claim', + async (_name, override) => { + const key = keyFixture('key-one'); + const idToken = signIdToken(key.privateKey, validClaims(override)); + const fetcher: FixedEndpointFetch = async (url) => + url === TOKEN_ENDPOINT + ? jsonResponse({ id_token: idToken }) + : jsonResponse({ keys: [key.jwk] }); + + await expect( + createClient(fetcher).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google ID token claims are invalid'); + }, + ); + + it('rejects algorithm confusion, forged signatures, and malformed key sets', async () => { + const trustedKey = keyFixture('key-one'); + const attackerKey = keyFixture('key-attacker'); + const invalidTokens = [ + signIdToken(trustedKey.privateKey, validClaims(), { alg: 'HS256' }), + signIdToken(attackerKey.privateKey, validClaims()), + ]; + + for (const idToken of invalidTokens) { + const fetcher: FixedEndpointFetch = async (url) => + url === TOKEN_ENDPOINT + ? jsonResponse({ id_token: idToken }) + : jsonResponse({ keys: [trustedKey.jwk] }); + await expect( + createClient(fetcher).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google ID token is invalid'); + } + + const duplicateKeys: FixedEndpointFetch = async (url) => + url === TOKEN_ENDPOINT + ? jsonResponse({ + id_token: signIdToken(trustedKey.privateKey, validClaims()), + }) + : jsonResponse({ keys: [trustedKey.jwk, trustedKey.jwk] }); + await expect( + createClient(duplicateKeys).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google signing key set is invalid'); + + const weakKey = keyFixture('key-weak', 1_024); + const weakKeySet: FixedEndpointFetch = async (url) => + url === TOKEN_ENDPOINT + ? jsonResponse({ + id_token: signIdToken(weakKey.privateKey, validClaims(), { + kid: 'key-weak', + }), + }) + : jsonResponse({ keys: [weakKey.jwk] }); + await expect( + createClient(weakKeySet).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google signing key set is invalid'); + }); + + it('fails closed before network access for invalid PKCE and configuration', async () => { + const fetcher: FixedEndpointFetch = vi.fn(async () => jsonResponse({})); + await expect( + createClient(fetcher).authenticateAuthorizationCode({ + ...authenticationInput(), + codeVerifier: 'too-short', + }), + ).rejects.toThrow('Google PKCE verifier is invalid'); + expect(fetcher).not.toHaveBeenCalled(); + + expect(() => + createClient(fetcher, { + redirectUri: 'http://identity.example.test/callback', + }), + ).toThrow('OAuth redirect URI must use HTTPS except on loopback hosts'); + expect(() => createClient(fetcher, { requestTimeoutMs: 99 })).toThrow( + 'Google request timeout is invalid', + ); + expect(() => createClient(fetcher, { clockSkewSeconds: 301 })).toThrow( + 'Google token clock skew is invalid', + ); + }); + + it('never exposes provider error bodies and rejects oversized responses', async () => { + const providerError: FixedEndpointFetch = async () => + jsonResponse( + { error: 'invalid_grant', error_description: 'secret-provider-detail' }, + { status: 400 }, + ); + await expect( + createClient(providerError).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google token exchange failed'); + await expect( + createClient(providerError).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.not.toThrow('secret-provider-detail'); + + const oversized: FixedEndpointFetch = async () => + jsonResponse( + {}, + { headers: { 'content-length': String(64 * 1_024 + 1) } }, + ); + await expect( + createClient(oversized).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google identity provider response is invalid'); + }); + + it('cancels a chunked provider response when it exceeds the byte limit', async () => { + const cancel = vi.fn(); + const responseBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1_024 + 1)); + }, + cancel, + }); + const oversizedStream: FixedEndpointFetch = async () => ({ + status: 200, + headers: new TestHeaders(), + body: responseBody, + async text(): Promise { + throw new Error('streaming response must not use text()'); + }, + }); + + await expect( + createClient(oversizedStream).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google identity provider response is invalid'); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('turns transport failures into a stable provider error', async () => { + const fetcher: FixedEndpointFetch = async () => { + throw new Error('socket failure with sensitive diagnostics'); + }; + await expect( + createClient(fetcher).authenticateAuthorizationCode( + authenticationInput(), + ), + ).rejects.toThrow('Google identity provider request failed'); + }); +}); diff --git a/apps/identity-service/src/google-oidc-client.ts b/apps/identity-service/src/google-oidc-client.ts new file mode 100644 index 00000000..24e93182 --- /dev/null +++ b/apps/identity-service/src/google-oidc-client.ts @@ -0,0 +1,678 @@ +import { + createPublicKey, + timingSafeEqual, + verify, + type JsonWebKey, + type KeyObject, +} from 'node:crypto'; +import { requireSafeRedirectUri } from './oauth-redirect-uri'; + +const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'; +const GOOGLE_JWKS_ENDPOINT = 'https://www.googleapis.com/oauth2/v3/certs'; +const GOOGLE_ISSUERS = new Set([ + 'https://accounts.google.com', + 'accounts.google.com', +]); +const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/; +const BASE64URL_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/; +const SUBJECT_PATTERN = /^[\x21-\x7e]{1,255}$/; +const MAXIMUM_HTTP_RESPONSE_BYTES = 64 * 1024; +const MAXIMUM_JWT_SEGMENT_BYTES = 32 * 1024; +const MAXIMUM_JWKS_KEYS = 64; +const MINIMUM_RSA_MODULUS_BITS = 2_048; +const DEFAULT_REQUEST_TIMEOUT_MS = 5_000; +const DEFAULT_CLOCK_SKEW_SECONDS = 60; +const DEFAULT_JWKS_CACHE_SECONDS = 300; +const MAXIMUM_JWKS_CACHE_SECONDS = 24 * 60 * 60; + +interface FixedEndpointHeaders { + get(name: string): string | null; +} + +interface FixedEndpointResponse { + readonly status: number; + readonly headers: FixedEndpointHeaders; + readonly body?: ReadableStream | null; + text(): Promise; +} + +/** Request options accepted by the fixed Google provider endpoints. */ +export interface FixedEndpointFetchInit { + method: 'GET' | 'POST'; + headers: Record; + body?: string; + redirect: 'error'; + signal: AbortSignal; +} + +/** Injectable fixed-endpoint transport used by the Google OIDC verifier. */ +export type FixedEndpointFetch = ( + url: string, + init: FixedEndpointFetchInit, +) => Promise; + +/** Construction options for the Google OIDC verifier. */ +export interface GoogleOidcClientOptions { + clientId: string; + clientSecret?: string; + redirectUri: string; + fetch?: FixedEndpointFetch; + now?: () => Date; + requestTimeoutMs?: number; + clockSkewSeconds?: number; +} + +/** Authorization-code inputs retained by the server-side OAuth transaction. */ +export interface GoogleAuthorizationCodeInput { + code: string; + codeVerifier: string; + nonce: string; +} + +/** Verified Google identity claims safe to cross the identity boundary. */ +export interface VerifiedGoogleIdentity { + provider: 'google'; + subject: string; + issuer: 'https://accounts.google.com' | 'accounts.google.com'; + email?: string; + emailVerified?: boolean; + displayName?: string; + hostedDomain?: string; +} + +interface ParsedJwt { + header: Record; + claims: Record; + signature: Buffer; + signedContent: Buffer; +} + +interface CachedGoogleKeySet { + expiresAtMs: number; + keys: ReadonlyMap; +} + +function fail(message: string): never { + throw new Error(message); +} + +function requireBoundedText( + value: unknown, + message: string, + maximumLength: number, +): string { + if (typeof value !== 'string') { + return fail(message); + } + const normalized = value.trim(); + if ( + !normalized || + normalized.length > maximumLength || + /[\u0000-\u001f\u007f]/.test(normalized) + ) { + return fail(message); + } + return normalized; +} + +function requireBoundedInteger( + value: number | undefined, + defaultValue: number, + minimum: number, + maximum: number, + message: string, +): number { + const resolved = value ?? defaultValue; + if ( + !Number.isSafeInteger(resolved) || + resolved < minimum || + resolved > maximum + ) { + return fail(message); + } + return resolved; +} + +function parseJsonObject( + text: string, + message: string, +): Record { + if (!text || Buffer.byteLength(text, 'utf8') > MAXIMUM_HTTP_RESPONSE_BYTES) { + return fail(message); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return fail(message); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return fail(message); + } + return parsed as Record; +} + +function decodeBase64UrlSegment(segment: string, message: string): Buffer { + if ( + !BASE64URL_SEGMENT_PATTERN.test(segment) || + Buffer.byteLength(segment, 'ascii') > MAXIMUM_JWT_SEGMENT_BYTES + ) { + return fail(message); + } + const decoded = Buffer.from(segment, 'base64url'); + if (decoded.length === 0 || decoded.toString('base64url') !== segment) { + return fail(message); + } + return decoded; +} + +function parseJwt(idTokenValue: unknown): ParsedJwt { + const idToken = requireBoundedText( + idTokenValue, + 'Google ID token is invalid', + MAXIMUM_JWT_SEGMENT_BYTES * 3, + ); + const segments = idToken.split('.'); + if (segments.length !== 3) { + return fail('Google ID token is invalid'); + } + const [encodedHeader, encodedClaims, encodedSignature] = segments as [ + string, + string, + string, + ]; + const header = parseJsonObject( + decodeBase64UrlSegment( + encodedHeader, + 'Google ID token is invalid', + ).toString('utf8'), + 'Google ID token is invalid', + ); + const claims = parseJsonObject( + decodeBase64UrlSegment( + encodedClaims, + 'Google ID token is invalid', + ).toString('utf8'), + 'Google ID token is invalid', + ); + return { + header, + claims, + signature: decodeBase64UrlSegment( + encodedSignature, + 'Google ID token is invalid', + ), + signedContent: Buffer.from(`${encodedHeader}.${encodedClaims}`, 'ascii'), + }; +} + +function requireNumericDate(value: unknown, message: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + return fail(message); + } + return value; +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, 'utf8'); + const rightBuffer = Buffer.from(right, 'utf8'); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} + +function optionalClaimText( + value: unknown, + maximumLength: number, +): string | undefined { + if (value === undefined) { + return undefined; + } + return requireBoundedText( + value, + 'Google ID token claims are invalid', + maximumLength, + ); +} + +function parseCacheSeconds(cacheControl: string | null): number { + const match = cacheControl?.match(/(?:^|,)\s*max-age=(\d+)(?:\s*,|$)/i); + if (!match) { + return DEFAULT_JWKS_CACHE_SECONDS; + } + const seconds = Number(match[1]); + if (!Number.isSafeInteger(seconds) || seconds < 0) { + return DEFAULT_JWKS_CACHE_SECONDS; + } + return Math.min(seconds, MAXIMUM_JWKS_CACHE_SECONDS); +} + +function requireResponseLength(response: FixedEndpointResponse): void { + const contentLength = response.headers.get('content-length'); + if (contentLength === null) { + return; + } + const bytes = Number(contentLength); + if ( + !Number.isSafeInteger(bytes) || + bytes < 0 || + bytes > MAXIMUM_HTTP_RESPONSE_BYTES + ) { + return fail('Google identity provider response is invalid'); + } +} + +async function cancelResponseBody( + body: ReadableStream | null | undefined, +): Promise { + if (!body) { + return; + } + try { + await body.cancel(); + } catch { + // The standardized provider failure remains authoritative. + } +} + +async function readBoundedResponseText( + response: FixedEndpointResponse, +): Promise { + try { + requireResponseLength(response); + } catch { + await cancelResponseBody(response.body); + return fail('Google identity provider response is invalid'); + } + + if (!response.body) { + let text: string; + try { + text = await response.text(); + } catch { + return fail('Google identity provider response is invalid'); + } + if (Buffer.byteLength(text, 'utf8') > MAXIMUM_HTTP_RESPONSE_BYTES) { + return fail('Google identity provider response is invalid'); + } + return text; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true }); + let bytesRead = 0; + let text = ''; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + bytesRead += chunk.value.byteLength; + if (bytesRead > MAXIMUM_HTTP_RESPONSE_BYTES) { + return fail('Google identity provider response is invalid'); + } + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + return text; + } catch { + try { + await reader.cancel(); + } catch { + // The standardized provider failure remains authoritative. + } + return fail('Google identity provider response is invalid'); + } finally { + reader.releaseLock(); + } +} + +function requireGoogleJwk(value: unknown): { kid: string; key: KeyObject } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return fail('Google signing key set is invalid'); + } + const jwk = value as Record; + const kid = requireBoundedText( + jwk.kid, + 'Google signing key set is invalid', + 128, + ); + if ( + jwk.kty !== 'RSA' || + (jwk.alg !== undefined && jwk.alg !== 'RS256') || + (jwk.use !== undefined && jwk.use !== 'sig') || + typeof jwk.n !== 'string' || + typeof jwk.e !== 'string' + ) { + return fail('Google signing key set is invalid'); + } + if ( + jwk.key_ops !== undefined && + (!Array.isArray(jwk.key_ops) || !jwk.key_ops.includes('verify')) + ) { + return fail('Google signing key set is invalid'); + } + let key: KeyObject; + try { + key = createPublicKey({ key: jwk as JsonWebKey, format: 'jwk' }); + } catch { + return fail('Google signing key set is invalid'); + } + if (key.asymmetricKeyType !== 'rsa') { + return fail('Google signing key set is invalid'); + } + const modulusLength = key.asymmetricKeyDetails?.modulusLength; + if ( + typeof modulusLength !== 'number' || + modulusLength < MINIMUM_RSA_MODULUS_BITS + ) { + return fail('Google signing key set is invalid'); + } + return { kid, key }; +} + +/** + * Exchanges Google authorization codes and verifies the returned ID tokens + * locally before exposing a bounded identity claim set. + */ +export class GoogleOidcClient { + private readonly clientId: string; + private readonly clientSecret: string | undefined; + private readonly redirectUri: string; + private readonly fetcher: FixedEndpointFetch; + private readonly now: () => Date; + private readonly requestTimeoutMs: number; + private readonly clockSkewSeconds: number; + private keySet: CachedGoogleKeySet | undefined; + private pendingKeySet: Promise | undefined; + + constructor(options: GoogleOidcClientOptions) { + this.clientId = requireBoundedText( + options.clientId, + 'Google OAuth client ID is invalid', + 512, + ); + this.clientSecret = options.clientSecret + ? requireBoundedText( + options.clientSecret, + 'Google OAuth client secret is invalid', + 2_048, + ) + : undefined; + this.redirectUri = requireSafeRedirectUri(options.redirectUri); + this.fetcher = + options.fetch ?? + ((url, init) => fetch(url, init) as Promise); + this.now = options.now ?? (() => new Date()); + this.requestTimeoutMs = requireBoundedInteger( + options.requestTimeoutMs, + DEFAULT_REQUEST_TIMEOUT_MS, + 100, + 30_000, + 'Google request timeout is invalid', + ); + this.clockSkewSeconds = requireBoundedInteger( + options.clockSkewSeconds, + DEFAULT_CLOCK_SKEW_SECONDS, + 0, + 300, + 'Google token clock skew is invalid', + ); + } + + /** Exchanges one authorization code and returns only verified identity claims. */ + async authenticateAuthorizationCode( + input: GoogleAuthorizationCodeInput, + ): Promise { + const code = requireBoundedText( + input.code, + 'Google authorization code is invalid', + 4_096, + ); + if (!PKCE_VERIFIER_PATTERN.test(input.codeVerifier)) { + return fail('Google PKCE verifier is invalid'); + } + const nonce = requireBoundedText( + input.nonce, + 'Google nonce is invalid', + 512, + ); + const tokenResponse = await this.postTokenRequest(code, input.codeVerifier); + const idToken = requireBoundedText( + tokenResponse.id_token, + 'Google token response is invalid', + MAXIMUM_JWT_SEGMENT_BYTES * 3, + ); + return await this.verifyIdToken(idToken, nonce); + } + + private async postTokenRequest( + code: string, + codeVerifier: string, + ): Promise> { + const form = new URLSearchParams({ + client_id: this.clientId, + code, + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + }); + if (this.clientSecret) { + form.set('client_secret', this.clientSecret); + } + return await this.requestJson( + GOOGLE_TOKEN_ENDPOINT, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + redirect: 'error', + signal: AbortSignal.timeout(this.requestTimeoutMs), + }, + 'Google token exchange failed', + ); + } + + private async verifyIdToken( + idToken: string, + expectedNonce: string, + ): Promise { + const parsed = parseJwt(idToken); + if ( + parsed.header.alg !== 'RS256' || + (parsed.header.typ !== undefined && parsed.header.typ !== 'JWT') + ) { + return fail('Google ID token is invalid'); + } + const kid = requireBoundedText( + parsed.header.kid, + 'Google ID token is invalid', + 128, + ); + const key = await this.signingKey(kid); + if (!verify('RSA-SHA256', parsed.signedContent, key, parsed.signature)) { + return fail('Google ID token is invalid'); + } + return this.verifyClaims(parsed.claims, expectedNonce); + } + + private verifyClaims( + claims: Record, + expectedNonce: string, + ): VerifiedGoogleIdentity { + const issuer = requireBoundedText( + claims.iss, + 'Google ID token claims are invalid', + 64, + ); + if (!GOOGLE_ISSUERS.has(issuer)) { + return fail('Google ID token claims are invalid'); + } + if (claims.aud !== this.clientId) { + return fail('Google ID token claims are invalid'); + } + if (claims.azp !== undefined && claims.azp !== this.clientId) { + return fail('Google ID token claims are invalid'); + } + const nowSeconds = Math.floor(this.now().getTime() / 1_000); + const expiresAt = requireNumericDate( + claims.exp, + 'Google ID token claims are invalid', + ); + const issuedAt = requireNumericDate( + claims.iat, + 'Google ID token claims are invalid', + ); + if ( + expiresAt <= nowSeconds - this.clockSkewSeconds || + issuedAt > nowSeconds + this.clockSkewSeconds + ) { + return fail('Google ID token claims are invalid'); + } + if ( + claims.nbf !== undefined && + requireNumericDate(claims.nbf, 'Google ID token claims are invalid') > + nowSeconds + this.clockSkewSeconds + ) { + return fail('Google ID token claims are invalid'); + } + const nonce = requireBoundedText( + claims.nonce, + 'Google ID token claims are invalid', + 512, + ); + if (!constantTimeEqual(nonce, expectedNonce)) { + return fail('Google ID token claims are invalid'); + } + const subject = requireBoundedText( + claims.sub, + 'Google ID token claims are invalid', + 255, + ); + if (!SUBJECT_PATTERN.test(subject)) { + return fail('Google ID token claims are invalid'); + } + const email = optionalClaimText(claims.email, 320); + const displayName = optionalClaimText(claims.name, 256); + const hostedDomain = optionalClaimText(claims.hd, 253); + if ( + claims.email_verified !== undefined && + typeof claims.email_verified !== 'boolean' + ) { + return fail('Google ID token claims are invalid'); + } + return Object.freeze({ + provider: 'google' as const, + subject, + issuer: issuer as VerifiedGoogleIdentity['issuer'], + ...(email ? { email } : {}), + ...(claims.email_verified !== undefined + ? { emailVerified: claims.email_verified as boolean } + : {}), + ...(displayName ? { displayName } : {}), + ...(hostedDomain ? { hostedDomain } : {}), + }); + } + + private async signingKey(kid: string): Promise { + const nowMs = this.now().getTime(); + let refreshed = false; + if (!this.keySet || this.keySet.expiresAtMs <= nowMs) { + this.keySet = await this.sharedFetchKeySet(); + refreshed = true; + } + let key = this.keySet.keys.get(kid); + if (!key && !refreshed) { + this.keySet = await this.sharedFetchKeySet(); + key = this.keySet.keys.get(kid); + } + if (!key) { + return fail('Google ID token is invalid'); + } + return key; + } + + private async sharedFetchKeySet(): Promise { + const existing = this.pendingKeySet; + if (existing) { + return await existing; + } + const pending = this.fetchKeySet(); + this.pendingKeySet = pending; + try { + return await pending; + } finally { + if (this.pendingKeySet === pending) { + this.pendingKeySet = undefined; + } + } + } + + private async fetchKeySet(): Promise { + const response = await this.fetchResponse(GOOGLE_JWKS_ENDPOINT, { + method: 'GET', + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: AbortSignal.timeout(this.requestTimeoutMs), + }); + if (response.status !== 200) { + return fail('Google signing key retrieval failed'); + } + const body = parseJsonObject( + await readBoundedResponseText(response), + 'Google signing key set is invalid', + ); + if ( + !Array.isArray(body.keys) || + body.keys.length === 0 || + body.keys.length > MAXIMUM_JWKS_KEYS + ) { + return fail('Google signing key set is invalid'); + } + const keys = new Map(); + for (const value of body.keys) { + const parsed = requireGoogleJwk(value); + if (keys.has(parsed.kid)) { + return fail('Google signing key set is invalid'); + } + keys.set(parsed.kid, parsed.key); + } + return { + expiresAtMs: + this.now().getTime() + + parseCacheSeconds(response.headers.get('cache-control')) * 1_000, + keys, + }; + } + + private async requestJson( + url: string, + init: FixedEndpointFetchInit, + failureMessage: string, + ): Promise> { + const response = await this.fetchResponse(url, init); + if (response.status < 200 || response.status >= 300) { + return fail(failureMessage); + } + return parseJsonObject( + await readBoundedResponseText(response), + failureMessage, + ); + } + + private async fetchResponse( + url: string, + init: FixedEndpointFetchInit, + ): Promise { + try { + return await this.fetcher(url, init); + } catch { + return fail('Google identity provider request failed'); + } + } +} diff --git a/docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md b/docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md new file mode 100644 index 00000000..c4d5bade --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md @@ -0,0 +1,33 @@ +# Google OIDC Verifier Slice + +## Goal + +Add the fixed-endpoint, fail-closed provider boundary needed before a Google OAuth callback may provision an account or issue a LifeOS session. + +## Included + +- authorization-code exchange at Google's fixed token endpoint +- mandatory PKCE verifier submission using the stored transaction value +- strict redirect suppression, request timeout, response-size bounds, and generic upstream failures +- local RS256 verification against Google's fixed JWKS endpoint +- bounded JWKS caching with a forced refresh when an unknown key ID appears +- issuer, audience, authorized-presenter, expiration, issued-at, not-before, nonce, subject, and claim-type validation +- constant-time nonce comparison +- access and refresh token discard at the provider boundary +- regression coverage for key rotation, algorithm confusion, signature forgery, malformed claims, malformed key sets, response limits, and error redaction + +## Security decisions + +The implementation does not consume OpenID discovery metadata at runtime, accept caller-selected endpoints, follow redirects, call the debugging token-info endpoint, or return provider bearer tokens. Google keys are accepted only as RSA signing keys and ID tokens only under RS256. A callback must present the nonce and PKCE verifier recovered from the single-use server-side OAuth transaction. + +## Authoritative references + +- Google OpenID Connect reference: https://developers.google.com/identity/openid-connect/reference +- Google server-side ID-token verification guide: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token +- Google OAuth web-server flow: https://developers.google.com/identity/protocols/oauth2/web-server +- RFC 7636, Proof Key for Code Exchange: https://www.rfc-editor.org/rfc/rfc7636 +- RFC 7517, JSON Web Key: https://www.rfc-editor.org/rfc/rfc7517 + +## Follow-up + +The next slice should connect this verifier to a fixed Google callback controller, consume the one-time OAuth transaction, provision the PostgreSQL-backed external identity and personal workspace, issue a server-backed LifeOS session cookie, clear the OAuth browser cookie, and redirect to the fixed post-login route. diff --git a/package.json b/package.json index 6963f3fa..e8da06d4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md", + "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": {