diff --git a/apps/web/src/app/api/auth/native/token/route.test.ts b/apps/web/src/app/api/auth/native/token/route.test.ts index 577920c450..dad5faa1ef 100644 --- a/apps/web/src/app/api/auth/native/token/route.test.ts +++ b/apps/web/src/app/api/auth/native/token/route.test.ts @@ -152,7 +152,7 @@ describe('POST /api/auth/native/token', () => { }); describe('apple', () => { - it('builds args mirroring createAppleAccountInfo, autoLink=false, and mints a token', async () => { + it('builds args mirroring createAppleAccountInfo, autoLink=true, and mints a token', async () => { mockVerifyNativeAppleIdToken.mockResolvedValue({ sub: 'apple-sub-1', email: 'appleuser@example.com', @@ -175,7 +175,7 @@ describe('POST /api/auth/native/token', () => { provider_account_id: 'apple-sub-1', }), undefined, - false, + true, expect.any(Headers), undefined, undefined, @@ -195,7 +195,7 @@ describe('POST /api/auth/native/token', () => { expect(mockCreateOrUpdateUser).toHaveBeenCalledWith( expect.objectContaining({ google_user_name: 'appleuser' }), undefined, - false, + true, expect.any(Headers), undefined, undefined, @@ -299,7 +299,7 @@ describe('POST /api/auth/native/token', () => { }); describe('google', () => { - it('builds args mirroring createGoogleAccountInfo (using hd) and autoLink=false', async () => { + it('builds args mirroring createGoogleAccountInfo (using hd) and autoLink=true', async () => { mockVerifyNativeGoogleIdToken.mockResolvedValue({ sub: 'google-sub-1', email: 'googleuser@example.com', @@ -325,7 +325,7 @@ describe('POST /api/auth/native/token', () => { provider_account_id: 'google-sub-1', }), undefined, - false, + true, expect.any(Headers), undefined, undefined, @@ -344,7 +344,7 @@ describe('POST /api/auth/native/token', () => { expect(mockCreateOrUpdateUser).toHaveBeenCalledWith( expect.objectContaining({ hosted_domain: '@@personal@@' }), undefined, - false, + true, expect.any(Headers), undefined, undefined, diff --git a/apps/web/src/app/api/auth/native/token/route.ts b/apps/web/src/app/api/auth/native/token/route.ts index b1a6d671ac..19e7636a44 100644 --- a/apps/web/src/app/api/auth/native/token/route.ts +++ b/apps/web/src/app/api/auth/native/token/route.ts @@ -189,7 +189,8 @@ export async function POST(request: NextRequest) { provider_account_id: verified.sub, display_name: null, }; - autoLinkToExistingUser = false; + // Verified id token enforces email_verified, so the credential proves the email. + autoLinkToExistingUser = true; } else if (data.provider === 'google') { let verified; try { @@ -232,7 +233,8 @@ export async function POST(request: NextRequest) { provider_account_id: verified.sub, display_name: null, }; - autoLinkToExistingUser = false; + // Verified id token enforces email_verified, so the credential proves the email. + autoLinkToExistingUser = true; } else { // Email sign-in code path: reserve → settle → commit. const existingUser = await findUserByNormalizedEmail(data.email); diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index 3f9bf3ff7e..7b0bb5a0ab 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -641,6 +641,180 @@ describe('User', () => { expect(result.user.id).toBe(existing.id); expect(result.isNew).toBe(false); }); + + it('links a magic-link sign-in to an existing user with a google provider', async () => { + const existing = await insertTestUserAndGoogleAuth({ + google_user_email: 'link-email@example.com', + google_user_name: 'Google First', + hosted_domain: 'example.com', + }); + + const result = await createOrUpdateUser( + { + google_user_email: 'link-email@example.com', + google_user_name: 'Google First', + google_user_image_url: '', + hosted_domain: 'example.com', + provider: 'email', + provider_account_id: 'link-email@example.com', + }, + undefined, + true + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.user.id).toBe(existing.id); + expect(result.isNew).toBe(false); + + const providerRows = await db + .select() + .from(user_auth_provider) + .where(eq(user_auth_provider.kilo_user_id, existing.id)); + expect(providerRows).toHaveLength(2); + expect(providerRows.some(row => row.provider === 'email')).toBe(true); + }); + + it('links a google sign-in to an existing magic-link user', async () => { + const existing = await insertTestUser({ + google_user_email: 'link-google@example.com', + google_user_name: 'Email First', + }); + await db.insert(user_auth_provider).values({ + kilo_user_id: existing.id, + provider: 'email', + provider_account_id: 'link-google@example.com', + email: 'link-google@example.com', + avatar_url: '', + display_name: null, + hosted_domain: 'example.com', + }); + + const result = await createOrUpdateUser( + { + google_user_email: 'link-google@example.com', + google_user_name: 'Email First', + google_user_image_url: 'https://example.com/avatar.png', + hosted_domain: 'example.com', + provider: 'google', + provider_account_id: 'google-link-new', + }, + undefined, + true + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.user.id).toBe(existing.id); + expect(result.isNew).toBe(false); + + const providerRows = await db + .select() + .from(user_auth_provider) + .where(eq(user_auth_provider.kilo_user_id, existing.id)); + expect(providerRows).toHaveLength(2); + expect( + providerRows.some( + row => row.provider === 'google' && row.provider_account_id === 'google-link-new' + ) + ).toBe(true); + }); + + it('refuses to auto-link when the credential does not prove the email', async () => { + const existing = await insertTestUserAndGoogleAuth({ + google_user_email: 'no-proof@example.com', + google_user_name: 'No Proof', + hosted_domain: 'example.com', + }); + + const result = await createOrUpdateUser( + { + google_user_email: 'no-proof@example.com', + google_user_name: 'No Proof', + google_user_image_url: 'https://example.com/avatar.png', + hosted_domain: '@@github@@', + provider: 'github', + provider_account_id: 'github-no-proof', + }, + undefined, + false + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBe('DIFFERENT-OAUTH'); + + const providerRows = await db + .select() + .from(user_auth_provider) + .where(eq(user_auth_provider.kilo_user_id, existing.id)); + expect(providerRows).toHaveLength(1); + }); + + it('refuses a same-provider different-account sign-in even with proof', async () => { + await insertTestUserAndGoogleAuth({ + google_user_email: 'same-provider@example.com', + google_user_name: 'Same Provider', + hosted_domain: 'example.com', + }); + + const result = await createOrUpdateUser( + { + google_user_email: 'same-provider@example.com', + google_user_name: 'Same Provider', + google_user_image_url: 'https://example.com/avatar.png', + hosted_domain: 'example.com', + provider: 'google', + provider_account_id: 'google-other-sub', + }, + undefined, + true + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBe('DIFFERENT-OAUTH'); + }); + + it('keeps the dev-only upgrade path: any provider links to a user whose only provider is fake-login', async () => { + const existing = await insertTestUser({ + google_user_email: 'fake-upgrade@example.com', + google_user_name: 'Fake Upgrade', + }); + await db.insert(user_auth_provider).values({ + kilo_user_id: existing.id, + provider: 'fake-login', + provider_account_id: 'fake-fake-upgrade@example.com', + email: 'fake-upgrade@example.com', + avatar_url: '', + display_name: null, + hosted_domain: '@@fake@@', + }); + + const result = await createOrUpdateUser( + { + google_user_email: 'fake-upgrade@example.com', + google_user_name: 'Fake Upgrade', + google_user_image_url: 'https://example.com/avatar.png', + hosted_domain: '@@github@@', + provider: 'github', + provider_account_id: 'github-fake-upgrade', + }, + undefined, + false + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.user.id).toBe(existing.id); + + const providerRows = await db + .select() + .from(user_auth_provider) + .where(eq(user_auth_provider.kilo_user_id, existing.id)); + expect(providerRows).toHaveLength(2); + expect(providerRows.some(row => row.provider === 'github')).toBe(true); + }); }); describe('softDeleteUser', () => { diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index c29f01e835..c7f6ac276b 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -571,17 +571,14 @@ export async function createOrUpdateUser( const hasThisProvider = existingProviders.some(p => p.provider === args.provider); const onlyHasFakeLogin = existingProviders.length === 1 && existingProviders[0].provider === 'fake-login'; - const hasNoProviders = existingProviders.length === 0; // Link this new provider to the existing user if they don't already have it. // fake-login is placeholder auth (dev-only) - always allow upgrading from it. - // Otherwise, only link if autoLinkToExistingUser AND one of: - // - User has no providers (clean slate after admin reset) - // - Provider is WorkOS/fake-login (special upgrade paths) - const isUpgradeProvider = args.provider === 'workos' || args.provider === 'fake-login'; - const shouldLink = - !hasThisProvider && - (onlyHasFakeLogin || (autoLinkToExistingUser && (hasNoProviders || isUpgradeProvider))); + // Callers pass autoLinkToExistingUser=true only when the credential proves + // ownership of the email (consumed magic-link/code, or a provider-asserted + // email_verified claim). Proof authorizes the link; without proof the + // sign-in keeps the DIFFERENT-OAUTH refusal below. + const shouldLink = !hasThisProvider && (onlyHasFakeLogin || autoLinkToExistingUser); if (shouldLink) { let linkedUser = userByEmail; diff --git a/apps/web/src/lib/user/server-signin-callback.test.ts b/apps/web/src/lib/user/server-signin-callback.test.ts new file mode 100644 index 0000000000..8dfa4297bd --- /dev/null +++ b/apps/web/src/lib/user/server-signin-callback.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { randomUUID } from 'crypto'; + +const cookieStore = new Map(); +jest.mock('next/headers', () => ({ + headers: async () => new Headers({ 'x-forwarded-for': '203.0.113.7' }), + cookies: async () => ({ + get: (name: string) => cookieStore.get(name), + set: (name: string, value: string) => cookieStore.set(name, { name, value }), + delete: (name: string) => cookieStore.delete(name), + getAll: () => [...cookieStore.values()], + }), +})); +jest.mock('@/lib/user', () => ({ + ...(jest.requireActual('@/lib/user') as object), + createOrUpdateUser: jest.fn(), +})); +jest.mock('@/lib/stripe-client', () => ({ + createStripeCustomer: jest.fn(async () => ({ id: 'cus_test' })), + deleteStripeCustomer: jest.fn(async () => {}), +})); + +import jwt from 'jsonwebtoken'; +import { authOptions } from '@/lib/user/server'; +import { createOrUpdateUser } from '@/lib/user'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; + +const mockCreateOrUpdateUser = jest.mocked(createOrUpdateUser); + +const signIn = authOptions.callbacks!.signIn!; + +function setValidTurnstileCookie() { + cookieStore.set('turnstile_jwt', { + name: 'turnstile_jwt', + value: jwt.sign({ guid: randomUUID(), ip: '203.0.113.7' }, NEXTAUTH_SECRET, { + algorithm: 'HS256', + expiresIn: '5m', + }), + }); +} + +describe('authOptions.callbacks.signIn auto-link wiring', () => { + beforeEach(() => { + cookieStore.clear(); + mockCreateOrUpdateUser + .mockReset() + .mockResolvedValue({ success: true, user: { blocked_reason: null } as never, isNew: false }); + }); + + it('passes autoLink=true for a Google profile that asserts email_verified', async () => { + setValidTurnstileCookie(); + + const result = await signIn({ + user: { id: 'x', email: 'cb-google@example.com', name: 'CB Google', image: '' }, + account: { provider: 'google', providerAccountId: 'cb-google-sub', type: 'oauth' }, + profile: { email_verified: true, email: 'cb-google@example.com' }, + } as never); + + expect(result).toBe(true); + expect(mockCreateOrUpdateUser.mock.calls[0]?.[2]).toBe(true); + }); + + it('passes autoLink=false for a GitHub profile without an email_verified claim', async () => { + setValidTurnstileCookie(); + + const result = await signIn({ + user: { id: 'x', email: 'cb-github@example.com', name: 'CB GitHub', image: '' }, + account: { provider: 'github', providerAccountId: 'cb-github-id', type: 'oauth' }, + profile: { login: 'cbgithub' }, + } as never); + + expect(result).toBe(true); + expect(mockCreateOrUpdateUser.mock.calls[0]?.[2]).toBe(false); + }); + + it('passes autoLink=true for an email (magic link) sign-in', async () => { + const result = await signIn({ + user: { + id: 'email-cb-email@example.com', + email: 'cb-email@example.com', + name: 'cb-email', + image: '', + }, + account: { + provider: 'email', + providerAccountId: 'cb-email@example.com', + type: 'credentials', + }, + profile: undefined, + } as never); + + expect(result).toBe(true); + expect(mockCreateOrUpdateUser.mock.calls[0]?.[2]).toBe(true); + }); + + it('passes autoLink=true for an Apple profile with the string "true" email_verified claim', async () => { + setValidTurnstileCookie(); + + const result = await signIn({ + user: { id: 'x', email: 'cb-apple@example.com', name: 'CB Apple', image: '' }, + account: { provider: 'apple', providerAccountId: 'cb-apple-sub', type: 'oauth' }, + profile: { email_verified: 'true', email: 'cb-apple@example.com' }, + } as never); + + expect(result).toBe(true); + expect(mockCreateOrUpdateUser.mock.calls[0]?.[2]).toBe(true); + }); +}); diff --git a/apps/web/src/lib/user/server.test.ts b/apps/web/src/lib/user/server.test.ts index 63068feb39..023b4b379f 100644 --- a/apps/web/src/lib/user/server.test.ts +++ b/apps/web/src/lib/user/server.test.ts @@ -11,6 +11,7 @@ import { isBlockedTLD, parseLinkedInProfileName, parseAnacondaProfile, + profileProvesEmailOwnership, authOptions, getUserUUID, uuidSchema, @@ -278,6 +279,32 @@ describe('Anaconda OAuth provider', () => { }); }); +describe('profileProvesEmailOwnership', () => { + test('accepts a boolean true email_verified claim', () => { + expect(profileProvesEmailOwnership({ email_verified: true })).toBe(true); + }); + + test('accepts the string "true" email_verified claim (Apple)', () => { + expect(profileProvesEmailOwnership({ email_verified: 'true' })).toBe(true); + }); + + test('rejects a boolean false email_verified claim', () => { + expect(profileProvesEmailOwnership({ email_verified: false })).toBe(false); + }); + + test('rejects the string "false" email_verified claim', () => { + expect(profileProvesEmailOwnership({ email_verified: 'false' })).toBe(false); + }); + + test('rejects a profile without the email_verified claim', () => { + expect(profileProvesEmailOwnership({})).toBe(false); + }); + + test('rejects undefined', () => { + expect(profileProvesEmailOwnership(undefined)).toBe(false); + }); +}); + describe('getUserUUID', () => { test('should return the same UUID for a user with a valid UUID id', () => { const validUUID = '550e8400-e29b-41d4-a716-446655440000'; diff --git a/apps/web/src/lib/user/server.ts b/apps/web/src/lib/user/server.ts index 9b5105104e..6a304cae1d 100644 --- a/apps/web/src/lib/user/server.ts +++ b/apps/web/src/lib/user/server.ts @@ -360,6 +360,17 @@ export function parseLinkedInProfileName(profile: { ); } +/** + * An OAuth/OIDC sign-in proves ownership of its email only when the provider + * asserts the `email_verified` claim in the raw profile. Apple delivers the + * claim as the string "true"; treat that as verified. Providers without the + * claim (GitHub, GitLab, Discord) never prove the email here. + */ +export function profileProvesEmailOwnership(profile: unknown): boolean { + const emailVerified = (profile as { email_verified?: unknown } | undefined)?.email_verified; + return emailVerified === true || emailVerified === 'true'; +} + function createEmailAccountInfo( account: Account, user: NextUser | AdapterUser @@ -855,9 +866,12 @@ export const authOptions: NextAuthOptions = { } // Check if this is an account linking operation - // For email (magic link) auth, we auto-link to existing users since magic link - // is verified by email ownership - const autoLinkToExistingUser = isEmailAuth || isFakeLogin; + // Auto-link only when the credential proves ownership of the email: + // a magic link consumes an inbox token; fake-login is dev-only; an + // OAuth profile proves it via the provider's email_verified claim. + // Methods without proof keep the DIFFERENT-OAUTH refusal. + const autoLinkToExistingUser = + isEmailAuth || isFakeLogin || profileProvesEmailOwnership(profile); if (isAccountLinking) { logImpactReferralDebug('Auth flow skipped Impact tracking context extraction', { provider: accountInfo.provider,