Add OAuth identity foundation
This commit is contained in:
parent
a74ff58083
commit
8107458ce3
322
backend/src/modules/users/oauth-identity.service.spec.ts
Normal file
322
backend/src/modules/users/oauth-identity.service.spec.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
270
backend/src/modules/users/oauth-identity.service.ts
Normal file
270
backend/src/modules/users/oauth-identity.service.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { AccountController } from './account.controller';
|
|||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { DefaultUserService } from './default-user.service';
|
import { DefaultUserService } from './default-user.service';
|
||||||
import { DeviceLinkingService } from './device-linking.service';
|
import { DeviceLinkingService } from './device-linking.service';
|
||||||
|
import { OAuthIdentityService } from './oauth-identity.service';
|
||||||
import { OwnershipTransferService } from './ownership-transfer.service';
|
import { OwnershipTransferService } from './ownership-transfer.service';
|
||||||
import {
|
import {
|
||||||
BootstrapOwnerContextService,
|
BootstrapOwnerContextService,
|
||||||
@ -20,6 +21,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
AccountService,
|
AccountService,
|
||||||
DefaultUserService,
|
DefaultUserService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
BootstrapOwnerContextService,
|
BootstrapOwnerContextService,
|
||||||
{
|
{
|
||||||
@ -32,6 +34,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
OwnerContext,
|
OwnerContext,
|
||||||
AccountService,
|
AccountService,
|
||||||
DeviceLinkingService,
|
DeviceLinkingService,
|
||||||
|
OAuthIdentityService,
|
||||||
OwnershipTransferService,
|
OwnershipTransferService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user