From df8db2ba4f443f83e41d1c3df467e0fc67235f8a Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Wed, 23 Sep 2026 22:43:09 -0400 Subject: [PATCH 1/3] feat: add isNewOAuthAccount/resolveOAuthAction helpers + tests pure server-field-only OAuth new-vs-existing decision, no client clock Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/utils/__tests__/authHelpers.test.js | 133 ++++++++++++++++++ frontend/src/utils/authHelpers.js | 46 ++++++ 2 files changed, 179 insertions(+) create mode 100644 frontend/src/utils/__tests__/authHelpers.test.js create mode 100644 frontend/src/utils/authHelpers.js diff --git a/frontend/src/utils/__tests__/authHelpers.test.js b/frontend/src/utils/__tests__/authHelpers.test.js new file mode 100644 index 0000000..a135af0 --- /dev/null +++ b/frontend/src/utils/__tests__/authHelpers.test.js @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { isNewOAuthAccount, resolveOAuthAction } from '../authHelpers'; + +describe('isNewOAuthAccount', () => { + it('returns true when created_at and last_sign_in_at are close (2s apart)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:02.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); + + it('returns false when account is months old and last_sign_in_at is fresh', () => { + const user = { + created_at: '2023-01-01T00:00:00.000Z', + last_sign_in_at: '2024-06-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(false); + }); + + it('returns null when created_at is missing', () => { + const user = { last_sign_in_at: '2024-01-01T00:00:00.000Z' }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when last_sign_in_at is missing', () => { + const user = { created_at: '2024-01-01T00:00:00.000Z' }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when created_at is invalid/unparseable', () => { + const user = { + created_at: 'not-a-date', + last_sign_in_at: '2024-01-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when last_sign_in_at is invalid/unparseable', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: 'not-a-date', + }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when user is null', () => { + expect(isNewOAuthAccount(null)).toBeNull(); + }); + + it('returns null when user is undefined', () => { + expect(isNewOAuthAccount(undefined)).toBeNull(); + }); + + it('is true exactly at the threshold boundary (default 30000ms)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:30.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); + + it('is false just past the threshold boundary (default 30000ms)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:30.001Z', + }; + expect(isNewOAuthAccount(user)).toBe(false); + }); + + it('respects a custom threshold', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:05.000Z', + }; + expect(isNewOAuthAccount(user, 10000)).toBe(true); + expect(isNewOAuthAccount(user, 1000)).toBe(false); + }); + + it('handles last_sign_in_at before created_at (abs diff)', () => { + const user = { + created_at: '2024-01-01T00:00:10.000Z', + last_sign_in_at: '2024-01-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); +}); + +describe('resolveOAuthAction', () => { + const newUser = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:02.000Z', + }; + const existingUser = { + created_at: '2023-01-01T00:00:00.000Z', + last_sign_in_at: '2024-06-01T00:00:00.000Z', + }; + const unknownUser = { created_at: 'bad', last_sign_in_at: 'bad' }; + + const table = [ + // origin, user, hasProfile, expected + ['register', newUser, true, 'allow'], + ['register', newUser, false, 'allow'], + ['register', existingUser, true, 'block_existing'], + ['register', existingUser, false, 'block_existing'], + ['register', unknownUser, true, 'allow'], + ['register', unknownUser, false, 'allow'], + ['register', null, true, 'allow'], + ['register', null, false, 'allow'], + + ['login', newUser, true, 'allow'], + ['login', newUser, false, 'allow_to_profile'], + ['login', existingUser, true, 'allow'], + ['login', existingUser, false, 'allow_to_profile'], + ['login', unknownUser, true, 'allow'], + ['login', unknownUser, false, 'allow_to_profile'], + ['login', null, true, 'allow'], + ['login', null, false, 'allow_to_profile'], + ]; + + it.each(table)( + 'origin=%s hasProfile=%s -> %s', + (origin, user, hasProfile, expected) => { + expect(resolveOAuthAction({ origin, user, hasProfile })).toBe(expected); + } + ); + + it('falls through to allow for an unrecognized origin', () => { + expect( + resolveOAuthAction({ origin: 'something-else', user: newUser, hasProfile: false }) + ).toBe('allow'); + }); +}); diff --git a/frontend/src/utils/authHelpers.js b/frontend/src/utils/authHelpers.js new file mode 100644 index 0000000..5c4be2e --- /dev/null +++ b/frontend/src/utils/authHelpers.js @@ -0,0 +1,46 @@ +// Pure helpers for deciding OAuth (Google) sign-in/sign-up behavior. +// No client-side clocks (new Date()/Date.now()) - decisions are based only +// on server-provided fields (user.created_at, user.last_sign_in_at) so the +// result can't drift due to network latency or client clock skew. + +/** + * Decide whether a Supabase auth user looks like a brand-new account, based + * only on server-provided timestamps. + * + * @param {{ created_at?: string, last_sign_in_at?: string } | null | undefined} user + * @param {number} thresholdMs + * @returns {boolean|null} true if new, false if existing, null if unknown + */ +export function isNewOAuthAccount(user, thresholdMs = 30000) { + if (!user) return null; + + const createdAt = Date.parse(user.created_at); + const lastSignInAt = Date.parse(user.last_sign_in_at); + + if (Number.isNaN(createdAt) || Number.isNaN(lastSignInAt)) return null; + + const diff = Math.abs(lastSignInAt - createdAt); + return diff <= thresholdMs; +} + +/** + * Decide what action to take for a Google OAuth sign-in, based on where the + * flow originated and server-known account state. + * + * @param {{ origin: 'register'|'login'|string, user: object|null|undefined, hasProfile: boolean }} params + * @returns {'allow'|'block_existing'|'allow_to_profile'} + */ +export function resolveOAuthAction({ origin, user, hasProfile }) { + if (origin === 'register') { + const isNew = isNewOAuthAccount(user); + if (isNew === false) return 'block_existing'; + return 'allow'; + } + + if (origin === 'login') { + if (!hasProfile) return 'allow_to_profile'; + return 'allow'; + } + + return 'allow'; +} From b47e1091a57a5d82fe64e37e883eda8808e7b2d5 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Wed, 23 Sep 2026 22:44:59 -0400 Subject: [PATCH 2/3] fix: use authHelpers for OAuth register/login decisions register: drop fragile 3s new Date() check, use server-field helper login: never signOut on missing user_profile row, redirect to /profile instead; query error now fails open (unknown), not treated as no-row also drop console.logs that leaked session.user fields Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/src/App.jsx | 68 +++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af8da39..df40fa8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { supabase } from './supabaseClient'; +import { resolveOAuthAction } from './utils/authHelpers'; import { GoalsProvider } from './contexts/GoalsContext'; import { FastingProvider } from './contexts/FastingContext'; import Landing from './pages/Landing'; @@ -88,27 +89,12 @@ function App() { // Keep loading state active during OAuth checks setCheckingOAuth(true); - // User clicked "Continue with Google" on register page - // Use Supabase's identity metadata to determine if this is truly a new account - // When OAuth creates a new account, the user object is created fresh - // When OAuth logs into existing account, the user object already existed + // User clicked "Continue with Google" on register page. + // Decide new-vs-existing from server timestamps only (see + // authHelpers.js) - no client clock, no user_profile lookup. + const action = resolveOAuthAction({ origin: 'register', user: session.user, hasProfile: null }); - console.log('OAuth signup attempt'); - console.log('User created_at:', session.user.created_at); - console.log('User email confirmed:', session.user.email_confirmed_at); - console.log('User identities:', session.user.identities); - - // Check if user was created very recently (within last 3 seconds) - const createdAt = new Date(session.user.created_at); - const now = new Date(); - const accountAge = now - createdAt; - - console.log('Account age (ms):', accountAge); - console.log('Account age (seconds):', accountAge / 1000); - - // If account is more than 3 seconds old, it's an existing account trying to sign up - if (accountAge > 3000) { - console.log('Blocking OAuth signup - account exists (age > 3s)'); + if (action === 'block_existing') { await supabase.auth.signOut(); localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); @@ -118,8 +104,7 @@ function App() { return; } - // Account is brand new (< 3 seconds old) - allow signup - console.log('New OAuth signup - allowing (account age < 3s)'); + // New account, or account age unknown - allow signup localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); setCheckingOAuth(false); @@ -137,30 +122,35 @@ function App() { .eq('user_id', session.user.id) .maybeSingle(); - const hasProfile = profileData !== null && !profileError; - - console.log('OAuth login - Profile exists:', hasProfile); - - if (!hasProfile) { - // No profile found - brand new account created during login attempt - // DO NOT set session - this prevents dashboard from showing - console.log('New account created during login - blocking'); - await supabase.auth.signOut(); + if (profileError) { + // Profile state unknown (query failed) - fail open to + // dashboard rather than treating it as a missing row. localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); - localStorage.setItem('oauth_login_error', 'No account found with this Google account. Please signup first.'); setCheckingOAuth(false); - window.location.replace('/login'); - return; // Exit without setting session - } else { - // Existing account - allow login - console.log('Existing OAuth user logging in - allowing'); - localStorage.removeItem('oauth_flow_origin'); - localStorage.removeItem('oauth_account_check'); + setSession(session); + return; + } + + const hasProfile = profileData !== null; + const action = resolveOAuthAction({ origin: 'login', user: session.user, hasProfile }); + + localStorage.removeItem('oauth_flow_origin'); + localStorage.removeItem('oauth_account_check'); + + if (action === 'allow_to_profile') { + // No profile row - never sign out over this; let them + // finish setting up their profile instead of locking out. + window.history.replaceState(null, '', '/profile'); setCheckingOAuth(false); setSession(session); return; } + + // Existing account with a profile - allow login + setCheckingOAuth(false); + setSession(session); + return; } catch (err) { console.error('Error checking profile:', err); // On error, default to allowing (fail open for better UX) From 14a745604f55c6a263d4e3113a1033b26c7a63ce Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Wed, 23 Sep 2026 22:47:31 -0400 Subject: [PATCH 3/3] test: label user in resolveOAuthAction table titles Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/src/utils/__tests__/authHelpers.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/__tests__/authHelpers.test.js b/frontend/src/utils/__tests__/authHelpers.test.js index a135af0..174b15f 100644 --- a/frontend/src/utils/__tests__/authHelpers.test.js +++ b/frontend/src/utils/__tests__/authHelpers.test.js @@ -119,7 +119,7 @@ describe('resolveOAuthAction', () => { ]; it.each(table)( - 'origin=%s hasProfile=%s -> %s', + 'origin=%s user=%o hasProfile=%s -> %s', (origin, user, hasProfile, expected) => { expect(resolveOAuthAction({ origin, user, hasProfile })).toBe(expected); }