Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 29 additions & 39 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -88,27 +89,12 @@
// 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');
Expand All @@ -118,8 +104,7 @@
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);
Expand All @@ -137,30 +122,35 @@
.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)
Expand All @@ -185,7 +175,7 @@
});

return () => subscription.unsubscribe();
}, []);

Check warning on line 178 in frontend/src/App.jsx

View workflow job for this annotation

GitHub Actions / Frontend Tests

React Hook useEffect has a missing dependency: 'isPasswordRecovery'. Either include it or remove the dependency array

if (loading || checkingOAuth) {
return (
Expand Down
133 changes: 133 additions & 0 deletions frontend/src/utils/__tests__/authHelpers.test.js
Original file line number Diff line number Diff line change
@@ -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 user=%o 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');
});
});
46 changes: 46 additions & 0 deletions frontend/src/utils/authHelpers.js
Original file line number Diff line number Diff line change
@@ -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';
}
Loading