diff --git a/prisma/migrations/20260317081600_normalize_user_emails/migration.sql b/prisma/migrations/20260317081600_normalize_user_emails/migration.sql new file mode 100644 index 0000000..1c7e8a0 --- /dev/null +++ b/prisma/migrations/20260317081600_normalize_user_emails/migration.sql @@ -0,0 +1,2 @@ +-- This migration was generated locally but superseded before it was applied. +-- It remains as a no-op to preserve Prisma migration history. diff --git a/prisma/migrations/20260317103000_make_username_required_unique/migration.sql b/prisma/migrations/20260317103000_make_username_required_unique/migration.sql new file mode 100644 index 0000000..49c9916 --- /dev/null +++ b/prisma/migrations/20260317103000_make_username_required_unique/migration.sql @@ -0,0 +1,27 @@ +UPDATE users +SET username = lower(split_part(email, '@', 1)) +WHERE username IS NULL OR btrim(username) = ''; + +WITH ranked_users AS ( + SELECT id, + username, + row_number() OVER (PARTITION BY username ORDER BY created_at, id) AS rn + FROM users +), +deduplicated AS ( + SELECT id, + CASE + WHEN rn = 1 THEN username + ELSE left(username, greatest(1, 24 - char_length(rn::text))) || rn::text + END AS next_username + FROM ranked_users +) +UPDATE users u +SET username = d.next_username +FROM deduplicated d +WHERE u.id = d.id; + +ALTER TABLE users +ALTER COLUMN username SET NOT NULL; + +CREATE UNIQUE INDEX users_username_key ON users (username); diff --git a/prisma/migrations/20260317110000_normalize_user_emails/migration.sql b/prisma/migrations/20260317110000_normalize_user_emails/migration.sql index 346c5e2..e94db3a 100644 --- a/prisma/migrations/20260317110000_normalize_user_emails/migration.sql +++ b/prisma/migrations/20260317110000_normalize_user_emails/migration.sql @@ -1,8 +1,19 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM ( + SELECT LOWER(TRIM("email")) AS normalized_email + FROM "users" + GROUP BY LOWER(TRIM("email")) + HAVING COUNT(*) > 1 + ) duplicates + ) THEN + RAISE EXCEPTION + 'Cannot normalize user emails: duplicate values would conflict after lowercasing/trimming'; + END IF; +END $$; + UPDATE "users" SET "email" = LOWER(TRIM("email")) WHERE "email" <> LOWER(TRIM("email")); - -ALTER TABLE "users" - DROP CONSTRAINT IF EXISTS "users_email_key"; - -CREATE UNIQUE INDEX "users_email_key" ON "users"(LOWER("email")); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 215cd3d..15fdb01 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,8 +24,8 @@ model User { /// User's email address email String @unique - /// User's preferred username from Keycloak - username String? + /// User's preferred app handle used for discovery + username String @unique /// URL to user's profile picture picture String? diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 7efa921..1c1f7e8 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -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' }), + }); + }); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 98775bd..eecda4e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + 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 { + 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, diff --git a/src/auth/auth.utils.ts b/src/auth/auth.utils.ts index 7c0550b..e538979 100644 --- a/src/auth/auth.utils.ts +++ b/src/auth/auth.utils.ts @@ -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); +}