Add OAuth identity foundation

This commit is contained in:
diyaa 2026-06-25 04:45:47 +02:00
parent a74ff58083
commit 8107458ce3
3 changed files with 595 additions and 0 deletions

View File

@ -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<string, any>([
[
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<string, any>();
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<string, unknown>;
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<string, unknown>;
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'));
});
});

View File

@ -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<string>(supportedOAuthProviders);
type OAuthIdentityClient = Pick<Prisma.TransactionClient, 'userOAuthIdentity' | 'user'>;
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<OAuthIdentityRecord> {
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<OAuthIdentityRecord> {
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<ResolvedOAuthIdentityUser> {
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`;
}
}

View File

@ -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,
],
})