From 33f5b6644ce8376caad7e593e78cadaef6e86d93 Mon Sep 17 00:00:00 2001 From: Grant Fletcher Date: Sat, 9 May 2026 05:20:30 -0400 Subject: [PATCH] fix: harden oauth security checks --- functions/src/auth.ts | 17 ++++++++++++++++ functions/src/oauth.ts | 46 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/functions/src/auth.ts b/functions/src/auth.ts index 8ff6cda..b1001fc 100644 --- a/functions/src/auth.ts +++ b/functions/src/auth.ts @@ -16,6 +16,15 @@ export interface AuthContext { scopes: string[]; } +const OAUTH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days + +function timestampToMillis(value: unknown): number | undefined { + if (typeof (value as { toMillis?: unknown } | undefined)?.toMillis === 'function') { + return (value as { toMillis: () => number }).toMillis(); + } + return undefined; +} + /** * Resolves the authenticated user and their scopes from the request. * @@ -48,6 +57,14 @@ export async function resolveUser(req: Request): Promise { const oauthDoc = await admin.firestore().doc(`oauthTokens/${token}`).get(); if (oauthDoc.exists) { const data = oauthDoc.data()!; + const createdAtMs = timestampToMillis(data.createdAt); + const expiresAtMs = timestampToMillis(data.expiresAt) + ?? (createdAtMs !== undefined ? createdAtMs + OAUTH_TOKEN_TTL_MS : undefined); + if (expiresAtMs !== undefined && Date.now() >= expiresAtMs) { + await oauthDoc.ref.delete(); + throw new Error('Invalid credentials'); + } + let scopes = (data.scope as string || 'profile:read workout:read').split(' '); // If Claude requested the generic 'claudeai' scope, grant full workout access diff --git a/functions/src/oauth.ts b/functions/src/oauth.ts index fc65f50..bb7657e 100644 --- a/functions/src/oauth.ts +++ b/functions/src/oauth.ts @@ -11,6 +11,26 @@ const FIREBASE_CONFIG = { }; const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days + +function isRedirectUriAllowed(clientData: Record, redirectUri: string): boolean { + const redirectUris = clientData.redirectUris; + return Array.isArray(redirectUris) && redirectUris.includes(redirectUri); +} + +function isTimingSafeMatch(storedSecret: unknown, providedSecret: unknown): boolean { + if (typeof storedSecret !== 'string' || typeof providedSecret !== 'string') { + return false; + } + + const storedBuffer = Buffer.from(storedSecret); + const providedBuffer = Buffer.from(providedSecret); + if (storedBuffer.length !== providedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(storedBuffer, providedBuffer); +} function loginPage(clientId: string, redirectUri: string, state: string, scope: string): string { const apiKey = FIREBASE_API_KEY.value(); @@ -146,6 +166,11 @@ export async function handleOAuthRequest(req: any, res: any) { res.status(400).json({ error: 'Invalid client_id' }); return; } + const clientData = clientDoc.data() as Record; + if (!isRedirectUriAllowed(clientData, redirect_uri)) { + res.status(400).json({ error: 'Invalid redirect_uri' }); + return; + } const params = new URLSearchParams({ client_id, @@ -166,6 +191,17 @@ export async function handleOAuthRequest(req: any, res: any) { return; } + const clientDoc = await admin.firestore().doc(`oauthClients/${client_id}`).get(); + if (!clientDoc.exists) { + res.status(400).json({ error: 'Invalid client_id' }); + return; + } + const clientData = clientDoc.data() as Record; + if (!isRedirectUriAllowed(clientData, redirect_uri)) { + res.status(400).json({ error: 'Invalid redirect_uri' }); + return; + } + const html = loginPage(client_id, redirect_uri, state, scope || ''); res.status(200).send(html); return; @@ -185,6 +221,11 @@ export async function handleOAuthRequest(req: any, res: any) { res.status(400).json({ error: 'Invalid client_id' }); return; } + const clientData = clientDoc.data() as Record; + if (!isRedirectUriAllowed(clientData, redirectUri)) { + res.status(400).json({ error: 'Invalid redirect_uri' }); + return; + } // Verify Firebase ID token let userId: string; @@ -229,7 +270,8 @@ export async function handleOAuthRequest(req: any, res: any) { // Validate client credentials const clientDoc = await admin.firestore().doc(`oauthClients/${client_id}`).get(); - if (!clientDoc.exists || clientDoc.data()!.secret !== client_secret) { + const clientData = clientDoc.data() as Record | undefined; + if (!clientDoc.exists || !clientData || !isTimingSafeMatch(clientData.secret, client_secret)) { res.status(401).json({ error: 'invalid_client' }); return; } @@ -268,6 +310,7 @@ export async function handleOAuthRequest(req: any, res: any) { clientId: client_id, scope: codeData.scope, // Inherit scope from authorization code createdAt: admin.firestore.FieldValue.serverTimestamp(), + expiresAt: admin.firestore.Timestamp.fromMillis(Date.now() + TOKEN_TTL_MS), }); // Delete used code @@ -276,6 +319,7 @@ export async function handleOAuthRequest(req: any, res: any) { res.status(200).json({ access_token: accessToken, token_type: 'Bearer', + expires_in: Math.floor(TOKEN_TTL_MS / 1000), scope: codeData.scope, }); return;