Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { seconds, ThrottlerModule } from '@nestjs/throttler'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { AuthorizationService } from './service/authorization.service'
import { AuthorityCheckerService } from './service/authority-checker.service'
import { UserAuthProviderService } from '@/common/user-auth-provider.service'

@Global()
@Module({
Expand Down Expand Up @@ -42,6 +43,7 @@ import { AuthorityCheckerService } from './service/authority-checker.service'
AuthService,
AuthorizationService,
AuthorityCheckerService,
UserAuthProviderService,
GithubOAuthStrategyFactory,
{
provide: GithubStrategy,
Expand Down
63 changes: 33 additions & 30 deletions apps/api/src/auth/service/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isEmail, isIP } from 'class-validator'
import { sEncrypt } from '@/common/cryptography'
import { WorkspaceCacheService } from '@/cache/workspace-cache.service'
import { TokenService } from '@/common/token.service'
import { UserAuthProviderService } from '@/common/user-auth-provider.service'
import dayjs from 'dayjs'
import { UAParser } from 'ua-parser-js'

Expand All @@ -36,7 +37,8 @@ export class AuthService {
private readonly cache: UserCacheService,
private readonly slugGenerator: SlugGenerator,
private readonly hydrationService: HydrationService,
private readonly workspaceCacheService: WorkspaceCacheService
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly userAuthProviderService: UserAuthProviderService
) {}

/**
Expand Down Expand Up @@ -485,37 +487,38 @@ export class AuthService {
)
}

// If the user has used OAuth to log in, we need to check if the OAuth provider
// used in the current login is different from the one stored in the database.

// If the CLI was used to sign up, we don't need to check for OAuth provider mismatch.
if (mode !== 'cli' && user.authProvider !== authProvider) {
let formattedAuthProvider = ''

switch (user.authProvider) {
case AuthProvider.GOOGLE:
formattedAuthProvider = 'Google'
break
case AuthProvider.GITHUB:
formattedAuthProvider = 'GitHub'
break
case AuthProvider.EMAIL_OTP:
formattedAuthProvider = 'Email and OTP'
break
case AuthProvider.GITLAB:
formattedAuthProvider = 'GitLab'
break
}

this.logger.error(
`User ${email} has signed up with ${user.authProvider}, but attempted to log in with ${authProvider}`
// Handle auth provider linking for existing users
if (mode !== 'cli') {
// Check if the user already has this auth provider
const hasProvider = this.userAuthProviderService.hasAuthProvider(
user,
authProvider
)
throw new BadRequestException(
constructErrorBody(
'Error signing in',
`You have already signed up with ${formattedAuthProvider}. Please use the same to sign in.`

if (!hasProvider) {
this.logger.log(
`User ${email} is signing in with new auth provider ${authProvider}. Adding to their account.`
)
)

try {
// Add the new auth provider to the user's account
await this.userAuthProviderService.addAuthProvider(
user.id,
authProvider
)
this.logger.log(
`Successfully linked ${authProvider} to user ${email}'s account`
)
} catch (error) {
this.logger.warn(
`Could not link auth provider ${authProvider} to user ${email}: ${error.message}. This may be expected if the migration hasn't run yet.`
)
}
} else {
this.logger.log(
`User ${email} already has auth provider ${authProvider} linked`
)
}
}

return user
Expand Down
221 changes: 221 additions & 0 deletions apps/api/src/common/user-auth-provider.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { Test, TestingModule } from '@nestjs/testing'
import { UserAuthProviderService } from './user-auth-provider.service'
import { PrismaService } from '@/prisma/prisma.service'
import { AuthProvider } from '@prisma/client'

describe('UserAuthProviderService', () => {
let service: UserAuthProviderService
let prisma: PrismaService

const mockUser = {
id: 'user-1',
email: '[email protected]',
name: 'Test User',
authProvider: AuthProvider.EMAIL_OTP,
authProviders: [AuthProvider.EMAIL_OTP],
isActive: true,
isAdmin: false,
isOnboardingFinished: false,
joinedOn: new Date(),
referralCode: 'ABC123',
profilePictureUrl: null,
referredById: null,
timesRemindedForOnboarding: 0
}

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserAuthProviderService,
{
provide: PrismaService,
useValue: {
$queryRaw: jest.fn(),
$executeRaw: jest.fn(),
user: {
findUnique: jest.fn()
}
}
}
]
}).compile()

service = module.get<UserAuthProviderService>(UserAuthProviderService)
prisma = module.get<PrismaService>(PrismaService)
})

describe('hasAuthProvider', () => {
it('should return true if user has the auth provider in authProviders array', () => {
const user = {
...mockUser,
authProviders: [AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE]
}

expect(service.hasAuthProvider(user, AuthProvider.GOOGLE)).toBe(true)
expect(service.hasAuthProvider(user, AuthProvider.GITHUB)).toBe(false)
})

it('should fallback to legacy authProvider field if authProviders is empty', () => {
const user = {
...mockUser,
authProvider: AuthProvider.GITHUB,
authProviders: []
}

expect(service.hasAuthProvider(user, AuthProvider.GITHUB)).toBe(true)
expect(service.hasAuthProvider(user, AuthProvider.GOOGLE)).toBe(false)
})

it('should handle missing authProviders field gracefully', () => {
const user = {
...mockUser,
authProvider: AuthProvider.GOOGLE
}
delete (user as any).authProviders

expect(service.hasAuthProvider(user, AuthProvider.GOOGLE)).toBe(true)
})
})

describe('getAuthProviders', () => {
it('should return authProviders array if present', () => {
const user = {
...mockUser,
authProviders: [AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE]
}

const providers = service.getAuthProviders(user)
expect(providers).toEqual([AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE])
})

it('should fallback to legacy authProvider if authProviders is empty', () => {
const user = {
...mockUser,
authProvider: AuthProvider.GITHUB,
authProviders: []
}

const providers = service.getAuthProviders(user)
expect(providers).toEqual([AuthProvider.GITHUB])
})

it('should return empty array if no auth providers exist', () => {
const user = {
...mockUser,
authProvider: null,
authProviders: []
}

const providers = service.getAuthProviders(user)
expect(providers).toEqual([])
})
})

describe('getPrimaryAuthProvider', () => {
it('should return first provider from authProviders array', () => {
const user = {
...mockUser,
authProviders: [AuthProvider.GOOGLE, AuthProvider.EMAIL_OTP]
}

const primary = service.getPrimaryAuthProvider(user)
expect(primary).toBe(AuthProvider.GOOGLE)
})

it('should fallback to legacy authProvider if authProviders is empty', () => {
const user = {
...mockUser,
authProvider: AuthProvider.GITHUB,
authProviders: []
}

const primary = service.getPrimaryAuthProvider(user)
expect(primary).toBe(AuthProvider.GITHUB)
})

it('should return null if no auth providers exist', () => {
const user = {
...mockUser,
authProvider: null,
authProviders: []
}

const primary = service.getPrimaryAuthProvider(user)
expect(primary).toBeNull()
})
})

describe('addAuthProvider', () => {
it('should add new auth provider to user', async () => {
const userQueryResult = [mockUser]
const updatedUserResult = [
{
...mockUser,
authProviders: [AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE]
}
]

jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce(userQueryResult)
jest.spyOn(prisma, '$executeRaw').mockResolvedValueOnce(undefined)
jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce(updatedUserResult)

const result = await service.addAuthProvider(
'user-1',
AuthProvider.GOOGLE
)

expect((result as any).authProviders).toContain(AuthProvider.GOOGLE)
expect(prisma.$executeRaw).toHaveBeenCalledWith(
expect.anything(), // The template literal creates a more complex structure
expect.arrayContaining([AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE]),
'user-1'
)
})

it('should not duplicate existing auth provider', async () => {
const userWithGoogle = {
...mockUser,
authProviders: [AuthProvider.EMAIL_OTP, AuthProvider.GOOGLE]
}
const userQueryResult = [userWithGoogle]

jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce(userQueryResult)

const result = await service.addAuthProvider(
'user-1',
AuthProvider.GOOGLE
)

expect(result).toEqual(userWithGoogle)
expect(prisma.$executeRaw).not.toHaveBeenCalled()
})

it('should handle database errors gracefully', async () => {
const userQueryResult = [mockUser]

jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce(userQueryResult)
jest
.spyOn(prisma, '$executeRaw')
.mockRejectedValueOnce(new Error('Database error'))

const result = await service.addAuthProvider(
'user-1',
AuthProvider.GOOGLE
)

// Should return original user if update fails
expect(result).toEqual(mockUser)
})

it('should throw error if user not found', async () => {
jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce([])

await expect(
service.addAuthProvider('nonexistent', AuthProvider.GOOGLE)
).rejects.toThrow('User with ID nonexistent not found')
})
})

// Note: migrateUserAuthProvider tests removed as migration is now handled by SQL script
// See: /apps/api/src/prisma/migrations/20250122000000_migrate_auth_provider_to_auth_providers_array/migration.sql
})
Loading
Loading