-
298 lines across 19 files. Where do you start?
A diff is ordered by file name, not by what matters. Reviews start at line one and run out of attention long before the change that counts.
-
Witness reads it first.
It indexes the PR into modules, entry points and call paths, so you review the shape of the change before its lines.
-
Then points at the line that matters.
Your Claude Code reads the code and picks the one changed line that is the change. Witness shows the real hunk around it, never a paraphrase.
-
And explains it in a minute.
Before and after, how it works, where the change lives. Narrated, captioned word by word, made once for the whole team.
src/auth-apple/auth-apple.service.ts -import { Injectable } from '@nestjs/common'; +import { + HttpStatus, + Injectable, + UnprocessableEntityException, +} from '@nestjs/common'; import appleSigninAuth from 'apple-signin-auth'; import { ConfigService } from '@nestjs/config'; import { SocialInterface } from '../social/interfaces/social.interface'; audience: this.configService.get('apple.appAudience', { infer: true }), }); + // Apple types this claim as `'true' | 'false' | boolean`, so both truthy + // forms are matched explicitly and anything else fails closed. + const emailVerified = + data.email_verified === true || data.email_verified === 'true'; + + // An unverified email must never reach validateSocialLogin: it matches + // accounts by email, so trusting it would allow account takeover. + if (data.email && !emailVerified) { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailNotVerified', + }, + }); + } + return { id: data.sub, email: data.email, + emailVerified, firstName: loginDto.firstName, lastName: loginDto.lastName, }; src/auth-facebook/auth-facebook.service.ts return { id: data.id, email: data.email || undefined, // Email may not be present depending on user permissions + emailVerified: Boolean(data.email), firstName: data.first_name || '', lastName: data.last_name || '', }; src/auth-google/auth-google.service.ts }); } + const emailVerified = data.email_verified === true; + + if (data.email && !emailVerified) { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailNotVerified', + }, + }); + } + return { id: data.sub, email: data.email, + emailVerified, firstName: data.given_name, lastName: data.family_name, }; src/auth/auth.service.ts const user = await this.usersService.findByEmail(loginDto.email); if (!user) { - throw new UnprocessableEntityException({ - status: HttpStatus.UNPROCESSABLE_ENTITY, - errors: { - email: 'notFound', - }, + throw this.invalidLoginException({ + email: 'notFound', }); } if (user.provider !== AuthProvidersEnum.email) { - throw new UnprocessableEntityException({ - status: HttpStatus.UNPROCESSABLE_ENTITY, - errors: { - email: `needLoginViaProvider:${user.provider}`, - }, + throw this.invalidLoginException({ + email: `needLoginViaProvider:${user.provider}`, }); } if (!user.password) { - throw new UnprocessableEntityException({ - status: HttpStatus.UNPROCESSABLE_ENTITY, - errors: { - password: 'incorrectPassword', - }, + throw this.invalidLoginException({ + password: 'incorrectPassword', }); } ); if (!isValidPassword) { - throw new UnprocessableEntityException({ - status: HttpStatus.UNPROCESSABLE_ENTITY, - errors: { - password: 'incorrectPassword', - }, + throw this.invalidLoginException({ + password: 'incorrectPassword', }); } const socialEmail = socialData.email?.toLowerCase(); let userByEmail: NullableType<User> = null; + if (socialEmail && !socialData.emailVerified) { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailNotVerified', + }, + }); + } + if (socialEmail) { userByEmail = await this.usersService.findByEmail(socialEmail); } await this.usersService.update(user.id, user); } else if (userByEmail) { user = userByEmail; + + if (user.status?.id?.toString() === StatusEnum.inactive.toString()) { + user.provider = authProvider; + user.socialId = socialData.id; + + await this.usersService.update(user.id, user); + } } else if (socialData.id) { const role = { id: RoleEnum.user, secret: this.configService.getOrThrow('auth.confirmEmailSecret', { infer: true, }), + algorithms: ['HS256'], }); userId = jwtData.confirmEmailUserId; secret: this.configService.getOrThrow('auth.confirmEmailSecret', { infer: true, }), + algorithms: ['HS256'], }); userId = jwtData.confirmEmailUserId; const user = await this.usersService.findByEmail(email); if (!user) { + const uniformErrors = this.configService.getOrThrow( + 'auth.uniformErrors', + { infer: true }, + ); + + // With uniform errors enabled we do not reveal whether the email is + // registered: respond exactly like the success case and skip the mail. + if (uniformErrors) { + return; + } + throw new UnprocessableEntityException({ status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { forgotUserId: user.id, }, { - secret: this.configService.getOrThrow('auth.forgotSecret', { - infer: true, - }), + secret: this.getForgotSecret(user), expiresIn: tokenExpiresIn, }, ); } async resetPassword(hash: string, password: string): Promise<void> { - let userId: User['id']; + let userId: User['id'] | undefined; try { - const jwtData = await this.jwtService.verifyAsync<{ - forgotUserId: User['id']; - }>(hash, { - secret: this.configService.getOrThrow('auth.forgotSecret', { - infer: true, - }), - }); + const jwtData = this.jwtService.decode<{ + forgotUserId?: User['id']; + } | null>(hash); - userId = jwtData.forgotUserId; + userId = jwtData?.forgotUserId; } catch { + userId = undefined; + } + + let user: NullableType<User> = null; + + if (userId !== undefined && userId !== null) { + try { + user = await this.usersService.findById(userId); + } catch { + user = null; + } + } + + if (!user) { throw new UnprocessableEntityException({ status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { }); } - const user = await this.usersService.findById(userId); - - if (!user) { + try { + await this.jwtService.verifyAsync(hash, { + secret: this.getForgotSecret(user), + algorithms: ['HS256'], + }); + } catch { throw new UnprocessableEntityException({ status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { - hash: `notFound`, + hash: `invalidHash`, }, }); } return this.sessionService.deleteById(data.sessionId); } + private invalidLoginException( + errors: Record<string, string>, + ): UnprocessableEntityException { + const uniformErrors = this.configService.getOrThrow('auth.uniformErrors', { + infer: true, + }); + + return new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: uniformErrors + ? { + email: 'incorrectEmailOrPassword', + password: 'incorrectEmailOrPassword', + } + : errors,

src/auth/auth.service.ts
}; } async validateSocialLogin( authProvider: string, socialData: SocialInterface, ): Promise<LoginResponseDto> { let user: NullableType<User> = null; const socialEmail = socialData.email?.toLowerCase(); let userByEmail: NullableType<User> = null; + if (socialEmail && !socialData.emailVerified) {+ throw new UnprocessableEntityException({+ status: HttpStatus.UNPROCESSABLE_ENTITY,+ errors: {+ email: 'emailNotVerified',
