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
12 changes: 6 additions & 6 deletions apps/web/src/app/api/auth/native/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -175,7 +175,7 @@ describe('POST /api/auth/native/token', () => {
provider_account_id: 'apple-sub-1',
}),
undefined,
false,
true,
expect.any(Headers),
undefined,
undefined,
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -325,7 +325,7 @@ describe('POST /api/auth/native/token', () => {
provider_account_id: 'google-sub-1',
}),
undefined,
false,
true,
expect.any(Headers),
undefined,
undefined,
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/app/api/auth/native/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
174 changes: 174 additions & 0 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
13 changes: 5 additions & 8 deletions apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/lib/user/server-signin-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import { randomUUID } from 'crypto';

const cookieStore = new Map<string, { name: string; value: string }>();
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);
});
});
Loading