diff --git a/backend/src/modules/users/oauth-identity.service.spec.ts b/backend/src/modules/users/oauth-identity.service.spec.ts new file mode 100644 index 0000000..3b1af35 --- /dev/null +++ b/backend/src/modules/users/oauth-identity.service.spec.ts @@ -0,0 +1,322 @@ +import { randomUUID } from 'node:crypto'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { UserAccountKind, UserAccountStatus } from '@prisma/client'; +import { OAuthIdentityService } from './oauth-identity.service'; + +describe('OAuthIdentityService', () => { + const activeUserId = randomUUID(); + const lockedUserId = randomUUID(); + const deletedUserId = randomUUID(); + + const buildService = () => { + const users = new Map([ + [ + activeUserId, + { + id: activeUserId, + slug: 'registered-owner', + displayName: 'Registered Owner', + isDefault: false, + accountKind: UserAccountKind.REGISTERED, + accountStatus: UserAccountStatus.ACTIVE, + libraryNamespace: randomUUID(), + }, + ], + [ + lockedUserId, + { + id: lockedUserId, + slug: 'locked-owner', + displayName: 'Locked Owner', + isDefault: false, + accountKind: UserAccountKind.REGISTERED, + accountStatus: UserAccountStatus.LOCKED, + libraryNamespace: randomUUID(), + }, + ], + [ + deletedUserId, + { + id: deletedUserId, + slug: 'deleted-owner', + displayName: 'Deleted Owner', + isDefault: false, + accountKind: UserAccountKind.REGISTERED, + accountStatus: UserAccountStatus.DELETED, + libraryNamespace: randomUUID(), + }, + ], + ]); + + const identities = new Map(); + + const toIdentityKey = (provider: string, providerSubject: string) => + `${provider}:${providerSubject}`; + + const userOAuthIdentity = { + findUnique: jest.fn().mockImplementation(async ({ where, include }) => { + const key = toIdentityKey( + where.provider_providerSubject.provider, + where.provider_providerSubject.providerSubject, + ); + const identity = identities.get(key) ?? null; + + if (!identity) { + return null; + } + + if (!include?.user) { + return identity; + } + + return { + ...identity, + user: users.get(identity.userId), + }; + }), + create: jest.fn().mockImplementation(async ({ data, select }) => { + const key = toIdentityKey(data.provider, data.providerSubject); + + if (identities.has(key)) { + throw { + code: 'P2002', + }; + } + + const identity = { + id: randomUUID(), + userId: data.userId, + provider: data.provider, + providerSubject: data.providerSubject, + email: data.email ?? null, + }; + identities.set(key, identity); + + if (select) { + return { + id: identity.id, + userId: identity.userId, + provider: identity.provider, + providerSubject: identity.providerSubject, + email: identity.email, + }; + } + + return identity; + }), + }; + + const user = { + findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + const record = users.get(where.id) ?? null; + + if (!record) { + return null; + } + + if (select) { + const typedRecord = record as Record; + return Object.fromEntries( + Object.keys(select).map((key) => [key, typedRecord[key]]), + ); + } + + return record; + }), + create: jest.fn().mockImplementation(async ({ data, select }) => { + const record = { + id: randomUUID(), + slug: data.slug, + displayName: data.displayName, + isDefault: data.isDefault, + accountKind: data.accountKind, + accountStatus: data.accountStatus, + libraryNamespace: data.libraryNamespace, + }; + users.set(record.id, record); + + if (select) { + const typedRecord = record as Record; + return Object.fromEntries( + Object.keys(select).map((key) => [key, typedRecord[key]]), + ); + } + + return record; + }), + }; + + const tx = { + userOAuthIdentity, + user, + }; + + const prismaService = { + userOAuthIdentity, + user, + $transaction: jest.fn().mockImplementation(async (callback) => callback(tx)), + } as any; + + const service = new OAuthIdentityService(prismaService); + + return { + service, + prismaService, + user, + userOAuthIdentity, + users, + identities, + toIdentityKey, + }; + }; + + it('resolves an existing OAuth identity to its current user', async () => { + const { service, identities, users, toIdentityKey } = buildService(); + identities.set(toIdentityKey('GOOGLE', 'google-subject-1'), { + id: randomUUID(), + userId: activeUserId, + provider: 'GOOGLE', + providerSubject: 'google-subject-1', + email: 'owner@example.com', + }); + + await expect( + service.resolveOrCreateUserForOAuthIdentity({ + provider: 'google', + providerSubject: 'google-subject-1', + email: 'updated@example.com', + }), + ).resolves.toEqual(users.get(activeUserId)); + }); + + it('creates a new registered active user for a new OAuth identity', async () => { + const { service, identities, toIdentityKey } = buildService(); + + const result = await service.resolveOrCreateUserForOAuthIdentity({ + provider: 'GITHUB', + providerSubject: 'github-subject-1', + email: ' NewUser@Example.com ', + }); + + expect(result.accountKind).toBe(UserAccountKind.REGISTERED); + expect(result.accountStatus).toBe(UserAccountStatus.ACTIVE); + expect(result.libraryNamespace).toEqual(expect.any(String)); + expect(result.slug).toMatch(/^oauth-github-/); + expect(result.displayName).toBe('newuser'); + expect( + identities.get(toIdentityKey('GITHUB', 'github-subject-1')), + ).toMatchObject({ + userId: result.id, + email: 'newuser@example.com', + }); + }); + + it('links a new OAuth identity to an existing active user', async () => { + const { service, identities, toIdentityKey } = buildService(); + + const result = await service.resolveOrCreateUserForOAuthIdentity({ + provider: 'APPLE', + providerSubject: 'apple-subject-1', + existingUserId: activeUserId, + email: 'Owner@Example.com', + }); + + expect(result.id).toBe(activeUserId); + expect(identities.get(toIdentityKey('APPLE', 'apple-subject-1'))).toMatchObject({ + userId: activeUserId, + email: 'owner@example.com', + }); + }); + + it('rejects duplicate provider and providerSubject linking', async () => { + const { service, identities, toIdentityKey } = buildService(); + identities.set(toIdentityKey('GOOGLE', 'shared-subject'), { + id: randomUUID(), + userId: randomUUID(), + provider: 'GOOGLE', + providerSubject: 'shared-subject', + email: null, + }); + + await expect( + service.linkOAuthIdentityToUser(activeUserId, { + provider: 'GOOGLE', + providerSubject: 'shared-subject', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects unsupported providers', () => { + const { service } = buildService(); + + expect(() => + service.validateOAuthIdentityRequest({ + provider: 'MICROSOFT', + providerSubject: 'subject-1', + }), + ).toThrow(new BadRequestException('Unsupported OAuth provider')); + }); + + it('rejects linking OAuth identities to locked users', async () => { + const { service } = buildService(); + + await expect( + service.linkOAuthIdentityToUser(lockedUserId, { + provider: 'GOOGLE', + providerSubject: 'subject-2', + }), + ).rejects.toThrow(new BadRequestException('Account is locked')); + }); + + it('rejects linking OAuth identities to deleted users', async () => { + const { service } = buildService(); + + await expect( + service.linkOAuthIdentityToUser(deletedUserId, { + provider: 'GOOGLE', + providerSubject: 'subject-3', + }), + ).rejects.toThrow(new BadRequestException('Account is deleted')); + }); + + it('does not use email alone as identity', async () => { + const { service, identities, toIdentityKey } = buildService(); + identities.set(toIdentityKey('GOOGLE', 'google-subject-email'), { + id: randomUUID(), + userId: activeUserId, + provider: 'GOOGLE', + providerSubject: 'google-subject-email', + email: 'owner@example.com', + }); + + const result = await service.resolveOrCreateUserForOAuthIdentity({ + provider: 'GOOGLE', + providerSubject: 'different-subject', + email: 'owner@example.com', + }); + + expect(result.id).not.toBe(activeUserId); + }); + + it('rejects missing providerSubject', () => { + const { service } = buildService(); + + expect(() => + service.validateOAuthIdentityRequest({ + provider: 'GOOGLE', + providerSubject: ' ', + }), + ).toThrow(new BadRequestException('providerSubject is required')); + }); + + it('throws when linking to an unknown user', async () => { + const { service } = buildService(); + + await expect( + service.linkOAuthIdentityToUser(randomUUID(), { + provider: 'GOOGLE', + providerSubject: 'subject-4', + }), + ).rejects.toThrow(new NotFoundException('Account not found')); + }); +}); diff --git a/backend/src/modules/users/oauth-identity.service.ts b/backend/src/modules/users/oauth-identity.service.ts new file mode 100644 index 0000000..ae97983 --- /dev/null +++ b/backend/src/modules/users/oauth-identity.service.ts @@ -0,0 +1,270 @@ +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + Prisma, + UserAccountKind, + UserAccountStatus, +} from '@prisma/client'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +export const supportedOAuthProviders = ['GOOGLE', 'APPLE', 'GITHUB'] as const; + +export type OAuthProvider = (typeof supportedOAuthProviders)[number]; + +const supportedOAuthProviderSet = new Set(supportedOAuthProviders); + +type OAuthIdentityClient = Pick; + +interface PrismaUniqueConstraintError { + code?: string; +} + +interface OAuthIdentityUserRecord { + id: string; + accountStatus: UserAccountStatus; +} + +export interface OAuthIdentityRequestInput { + provider: string; + providerSubject: string; + email?: string | null; +} + +export interface ValidatedOAuthIdentityRequest { + provider: OAuthProvider; + providerSubject: string; + email: string | null; +} + +export interface OAuthIdentityRecord { + id: string; + userId: string; + provider: OAuthProvider; + providerSubject: string; + email: string | null; +} + +export interface ResolvedOAuthIdentityUser { + id: string; + slug: string; + displayName: string; + isDefault: boolean; + accountKind: UserAccountKind; + accountStatus: UserAccountStatus; + libraryNamespace: string; +} + +export interface ResolveOrCreateUserForOAuthIdentityInput + extends OAuthIdentityRequestInput { + existingUserId?: string | null; +} + +@Injectable() +export class OAuthIdentityService { + constructor(private readonly prismaService: PrismaService) {} + + validateOAuthIdentityRequest( + request: OAuthIdentityRequestInput, + ): ValidatedOAuthIdentityRequest { + const provider = request.provider.trim().toUpperCase(); + const providerSubject = request.providerSubject.trim(); + const email = request.email?.trim().toLowerCase() || null; + + if (!supportedOAuthProviderSet.has(provider)) { + throw new BadRequestException('Unsupported OAuth provider'); + } + + if (!providerSubject) { + throw new BadRequestException('providerSubject is required'); + } + + return { + provider: provider as OAuthProvider, + providerSubject, + email, + }; + } + + async findOAuthIdentity( + request: OAuthIdentityRequestInput, + ): Promise<(OAuthIdentityRecord & { user: ResolvedOAuthIdentityUser }) | null> { + const validatedRequest = this.validateOAuthIdentityRequest(request); + + return this.prismaService.userOAuthIdentity.findUnique({ + where: { + provider_providerSubject: { + provider: validatedRequest.provider, + providerSubject: validatedRequest.providerSubject, + }, + }, + include: { + user: { + select: { + id: true, + slug: true, + displayName: true, + isDefault: true, + accountKind: true, + accountStatus: true, + libraryNamespace: true, + }, + }, + }, + }) as Promise<(OAuthIdentityRecord & { user: ResolvedOAuthIdentityUser }) | null>; + } + + async createOAuthIdentity( + client: OAuthIdentityClient, + userId: string, + request: OAuthIdentityRequestInput, + ): Promise { + const validatedRequest = this.validateOAuthIdentityRequest(request); + + try { + return (await client.userOAuthIdentity.create({ + data: { + userId, + provider: validatedRequest.provider, + providerSubject: validatedRequest.providerSubject, + email: validatedRequest.email, + }, + select: { + id: true, + userId: true, + provider: true, + providerSubject: true, + email: true, + }, + })) as OAuthIdentityRecord; + } catch (error) { + if ((error as PrismaUniqueConstraintError)?.code === 'P2002') { + throw new BadRequestException( + 'OAuth identity is already linked to another account', + ); + } + + throw error; + } + } + + async linkOAuthIdentityToUser( + userId: string, + request: OAuthIdentityRequestInput, + ): Promise { + const validatedRequest = this.validateOAuthIdentityRequest(request); + const targetUser = await this.prismaService.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + accountStatus: true, + }, + }); + + if (!targetUser) { + throw new NotFoundException('Account not found'); + } + + this.assertUserCanReceiveOAuthIdentity(targetUser); + + return this.prismaService.$transaction(async (tx) => + this.createOAuthIdentity(tx, targetUser.id, validatedRequest), + ); + } + + async resolveOrCreateUserForOAuthIdentity( + request: ResolveOrCreateUserForOAuthIdentityInput, + ): Promise { + const validatedRequest = this.validateOAuthIdentityRequest(request); + const existingIdentity = await this.findOAuthIdentity(validatedRequest); + + if (existingIdentity) { + return existingIdentity.user; + } + + if (request.existingUserId) { + const linkedIdentity = await this.linkOAuthIdentityToUser( + request.existingUserId, + validatedRequest, + ); + + const linkedUser = await this.prismaService.user.findUnique({ + where: { + id: linkedIdentity.userId, + }, + select: { + id: true, + slug: true, + displayName: true, + isDefault: true, + accountKind: true, + accountStatus: true, + libraryNamespace: true, + }, + }); + + if (!linkedUser) { + throw new NotFoundException('Account not found'); + } + + return linkedUser; + } + + return this.prismaService.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + slug: this.buildRegisteredUserSlug(validatedRequest.provider), + displayName: this.buildRegisteredUserDisplayName(validatedRequest), + isDefault: false, + accountKind: UserAccountKind.REGISTERED, + accountStatus: UserAccountStatus.ACTIVE, + libraryNamespace: randomUUID(), + }, + select: { + id: true, + slug: true, + displayName: true, + isDefault: true, + accountKind: true, + accountStatus: true, + libraryNamespace: true, + }, + }); + + await this.createOAuthIdentity(tx, user.id, validatedRequest); + + return user; + }); + } + + private assertUserCanReceiveOAuthIdentity(user: OAuthIdentityUserRecord): void { + if (user.accountStatus === UserAccountStatus.LOCKED) { + throw new BadRequestException('Account is locked'); + } + + if (user.accountStatus === UserAccountStatus.DELETED) { + throw new BadRequestException('Account is deleted'); + } + } + + private buildRegisteredUserSlug(provider: OAuthProvider): string { + return `oauth-${provider.toLowerCase()}-${randomUUID()}`; + } + + private buildRegisteredUserDisplayName( + request: ValidatedOAuthIdentityRequest, + ): string { + if (!request.email) { + return `${request.provider.toLowerCase()} user`; + } + + const localPart = request.email.split('@')[0]?.trim(); + + return localPart || `${request.provider.toLowerCase()} user`; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 281598e..6ac7152 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -6,6 +6,7 @@ import { AccountController } from './account.controller'; import { AccountService } from './account.service'; import { DefaultUserService } from './default-user.service'; import { DeviceLinkingService } from './device-linking.service'; +import { OAuthIdentityService } from './oauth-identity.service'; import { OwnershipTransferService } from './ownership-transfer.service'; import { BootstrapOwnerContextService, @@ -20,6 +21,7 @@ import { StorageModule } from '../storage/storage.module'; AccountService, DefaultUserService, DeviceLinkingService, + OAuthIdentityService, OwnershipTransferService, BootstrapOwnerContextService, { @@ -32,6 +34,7 @@ import { StorageModule } from '../storage/storage.module'; OwnerContext, AccountService, DeviceLinkingService, + OAuthIdentityService, OwnershipTransferService, ], })