auto-populate missing username field with email local-part
This commit is contained in:
@@ -50,6 +50,7 @@ describe('AuthService', () => {
|
||||
},
|
||||
user: {
|
||||
update: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -424,5 +425,89 @@ describe('AuthService', () => {
|
||||
data: expect.objectContaining({ providerEmail: 'jane@example.com' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('derives username from email local-part when provider username is missing', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const profileWithoutUsername: SocialAuthProfile = {
|
||||
...socialProfile,
|
||||
email: 'Alice@example.com',
|
||||
username: undefined,
|
||||
};
|
||||
|
||||
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
||||
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.user.findFirst = jest.fn().mockResolvedValue(null);
|
||||
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||
Promise.resolve(
|
||||
callback({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: txUserCreate,
|
||||
},
|
||||
authIdentity: {
|
||||
create: txIdentityCreate,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||
id: 'code-1',
|
||||
});
|
||||
|
||||
await service.completeSso('google', state, profileWithoutUsername);
|
||||
|
||||
expect(txUserCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ username: 'alice' }),
|
||||
});
|
||||
expect(txIdentityCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ providerUsername: 'alice' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a numeric suffix when derived username is already taken', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const profileWithoutUsername: SocialAuthProfile = {
|
||||
...socialProfile,
|
||||
email: 'Alice@example.com',
|
||||
username: undefined,
|
||||
};
|
||||
|
||||
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
||||
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.user.findFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'existing-user' })
|
||||
.mockResolvedValueOnce(null);
|
||||
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||
Promise.resolve(
|
||||
callback({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: txUserCreate,
|
||||
},
|
||||
authIdentity: {
|
||||
create: txIdentityCreate,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||
id: 'code-1',
|
||||
});
|
||||
|
||||
await service.completeSso('google', state, profileWithoutUsername);
|
||||
|
||||
expect(txUserCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ username: 'alice2' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
asProviderName,
|
||||
isLoopbackRedirect,
|
||||
normalizeEmail,
|
||||
normalizeUsername,
|
||||
randomOpaqueToken,
|
||||
sha256,
|
||||
usernameFromEmail,
|
||||
} from './auth.utils';
|
||||
import type { SsoProvider } from './dto/sso-provider';
|
||||
|
||||
@@ -220,6 +222,11 @@ export class AuthService {
|
||||
const normalizedProviderEmail = profile.email
|
||||
? normalizeEmail(profile.email)
|
||||
: null;
|
||||
const resolvedUsername = await this.resolveUsername(
|
||||
profile.username,
|
||||
normalizedProviderEmail,
|
||||
existingIdentity.user.id,
|
||||
);
|
||||
|
||||
await this.prisma.authIdentity.update({
|
||||
where: { id: existingIdentity.id },
|
||||
@@ -228,7 +235,7 @@ export class AuthService {
|
||||
? { providerEmail: normalizedProviderEmail }
|
||||
: {}),
|
||||
providerName: profile.displayName,
|
||||
providerUsername: profile.username,
|
||||
providerUsername: resolvedUsername,
|
||||
providerPicture: profile.picture,
|
||||
emailVerified: profile.emailVerified,
|
||||
},
|
||||
@@ -241,7 +248,7 @@ export class AuthService {
|
||||
? { email: normalizedProviderEmail }
|
||||
: {}),
|
||||
name: profile.displayName,
|
||||
username: profile.username,
|
||||
username: resolvedUsername,
|
||||
picture: profile.picture,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
@@ -255,6 +262,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const email = normalizeEmail(profile.email);
|
||||
const resolvedUsername = await this.resolveUsername(
|
||||
profile.username,
|
||||
email,
|
||||
);
|
||||
|
||||
if (!profile.emailVerified) {
|
||||
throw new BadRequestException(
|
||||
@@ -277,7 +288,7 @@ export class AuthService {
|
||||
data: {
|
||||
email,
|
||||
name: profile.displayName,
|
||||
username: profile.username,
|
||||
username: resolvedUsername,
|
||||
picture: profile.picture,
|
||||
roles: [],
|
||||
lastLoginAt: now,
|
||||
@@ -291,7 +302,7 @@ export class AuthService {
|
||||
providerSubject: profile.providerSubject,
|
||||
providerEmail: email,
|
||||
providerName: profile.displayName,
|
||||
providerUsername: profile.username,
|
||||
providerUsername: resolvedUsername,
|
||||
providerPicture: profile.picture,
|
||||
emailVerified: profile.emailVerified,
|
||||
userId: user.id,
|
||||
@@ -302,6 +313,58 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveUsername(
|
||||
providerUsername: string | undefined,
|
||||
email: string | null,
|
||||
excludeUserId?: string,
|
||||
): Promise<string> {
|
||||
const candidates = [
|
||||
providerUsername ? normalizeUsername(providerUsername) : '',
|
||||
email ? usernameFromEmail(email) : '',
|
||||
'friendoll',
|
||||
].filter(
|
||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
||||
);
|
||||
|
||||
for (const base of candidates) {
|
||||
const available = await this.isUsernameAvailable(base, excludeUserId);
|
||||
if (available) {
|
||||
return base;
|
||||
}
|
||||
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const maxBaseLength = Math.max(1, 24 - suffix.toString().length);
|
||||
const candidate = `${base.slice(0, maxBaseLength)}${suffix}`;
|
||||
const available = await this.isUsernameAvailable(
|
||||
candidate,
|
||||
excludeUserId,
|
||||
);
|
||||
if (available) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ServiceUnavailableException('Unable to assign a unique username');
|
||||
}
|
||||
|
||||
private async isUsernameAvailable(
|
||||
username: string,
|
||||
excludeUserId?: string,
|
||||
): Promise<boolean> {
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
...(excludeUserId ? { id: { not: excludeUserId } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !existing;
|
||||
}
|
||||
|
||||
private async issueTokens(
|
||||
userId: string,
|
||||
email: string,
|
||||
|
||||
@@ -28,3 +28,19 @@ export function asProviderName(value: SsoProvider): 'GOOGLE' | 'DISCORD' {
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
export function usernameFromEmail(email: string): string {
|
||||
const localPart = normalizeEmail(email).split('@')[0] ?? '';
|
||||
return normalizeUsername(localPart);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user