diff --git a/package-lock.json b/package-lock.json index b79a1b2..bb4def2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", "ioredis": "^5.4.1", + "jwk-to-pem": "^2.0.7", "lodash": "^4.17.21", "moment": "^2.30.1", "nestjs-i18n": "^10.4.9", @@ -65,6 +66,7 @@ "@types/express": "^5.0.0", "@types/google-libphonenumber": "^7.4.30", "@types/jest": "^29.5.2", + "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.13", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", @@ -2858,6 +2860,13 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/@types/jwk-to-pem": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/jwk-to-pem/-/jwk-to-pem-2.0.3.tgz", + "integrity": "sha512-I/WFyFgk5GrNbkpmt14auGO3yFK1Wt4jXzkLuI+fDBNtO5ZI2rbymyGd6bKzfSBEuyRdM64ZUwxU1+eDcPSOEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.13", "dev": true, @@ -3788,6 +3797,18 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-never": { "version": "1.3.0", "license": "MIT", @@ -4055,6 +4076,12 @@ "node": ">= 6" } }, + "node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "license": "MIT", @@ -4155,6 +4182,12 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.24.2", "dev": true, @@ -5514,6 +5547,21 @@ "dev": true, "license": "ISC" }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/emitter-listener": { "version": "1.1.2", "license": "BSD-2-Clause", @@ -6994,6 +7042,16 @@ "version": "2.0.1", "license": "ISC" }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hasown": { "version": "2.0.2", "license": "MIT", @@ -7031,6 +7089,17 @@ "node": "*" } }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, "node_modules/hookified": { "version": "1.5.1", "license": "MIT" @@ -8503,6 +8572,17 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwk-to-pem": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.7.tgz", + "integrity": "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==", + "license": "Apache-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "elliptic": "^6.6.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/jwks-rsa": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz", @@ -9532,6 +9612,18 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "9.0.5", "license": "ISC", diff --git a/package.json b/package.json index 22a7486..7b5d0d8 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", "ioredis": "^5.4.1", + "jwk-to-pem": "^2.0.7", "lodash": "^4.17.21", "moment": "^2.30.1", "nestjs-i18n": "^10.4.9", @@ -82,6 +83,7 @@ "@types/express": "^5.0.0", "@types/google-libphonenumber": "^7.4.30", "@types/jest": "^29.5.2", + "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.13", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index a4ddaa6..ce8e56b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,14 +1,15 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; import { AuthController } from './controllers'; -import { AuthService } from './services'; +import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ - imports: [JwtModule.register({}), JuniorModule, UserModule], - providers: [AuthService, AccessTokenStrategy], + imports: [JwtModule.register({}), JuniorModule, UserModule, HttpModule], + providers: [AuthService, AccessTokenStrategy, Oauth2Service], controllers: [AuthController], exports: [], }) diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index daf9d85..bc99efe 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -29,6 +29,12 @@ export class LoginRequestDto { @ValidateIf((o) => o.grantType === GrantType.GOOGLE) googleToken!: string; + @ApiProperty({ example: 'apple_token' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) + @ValidateIf((o) => o.grantType === GrantType.APPLE) + appleToken!: string; + @ApiProperty({ example: 'fcm-device-token' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) }) diff --git a/src/auth/interfaces/apple-payload.interface.ts b/src/auth/interfaces/apple-payload.interface.ts new file mode 100644 index 0000000..06a5d63 --- /dev/null +++ b/src/auth/interfaces/apple-payload.interface.ts @@ -0,0 +1,11 @@ +export interface ApplePayload { + iss: string; + aud: string; + exp: number; + iat: number; + sub: string; + c_hash: string; + auth_time: number; + nonce_supported: boolean; + email?: string; +} diff --git a/src/auth/interfaces/index.ts b/src/auth/interfaces/index.ts index a27228e..6598ce3 100644 --- a/src/auth/interfaces/index.ts +++ b/src/auth/interfaces/index.ts @@ -1,2 +1,3 @@ +export * from './apple-payload.interface'; export * from './jwt-payload.interface'; export * from './login-response.interface'; diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 9d8a75e..65a0b3f 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; -import { OAuth2Client } from 'google-auth-library'; +import { ArrayContains } from 'typeorm'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; @@ -22,19 +22,16 @@ import { setJuniorPasswordRequestDto, VerifyUserRequestDto, } from '../dtos/request'; -import { GrantType } from '../enums'; +import { GrantType, Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; +import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; const SALT_ROUNDS = 10; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); - private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID'); - private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID'); - private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID'); - private readonly client = new OAuth2Client(); constructor( private readonly otpService: OtpService, @@ -44,6 +41,7 @@ export class AuthService { private readonly deviceService: DeviceService, private readonly juniorTokenService: JuniorTokenService, private readonly cacheService: CacheService, + private readonly oauth2Service: Oauth2Service, ) {} async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`); @@ -269,7 +267,7 @@ export class AuthService { if (loginDto.grantType === GrantType.APPLE) { this.logger.log(`Logging in user with email ${loginDto.email} using apple`); - throw new BadRequestException('AUTH.APPLE_LOGIN_NOT_IMPLEMENTED'); + [tokens, user] = await this.loginWithApple(loginDto); } if (loginDto.grantType === GrantType.PASSWORD) { @@ -383,32 +381,64 @@ export class AuthService { } private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - try { - const ticket = await this.client.verifyIdToken({ - idToken: loginDto.googleToken, - audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId], - }); + const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); + const [existingUser, isJunior] = await Promise.all([ + this.userService.findUser({ googleId: sub }), + this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), + ]); - const payload = ticket.getPayload(); + if (isJunior && email) { + this.logger.error(`User with email ${email} is an already registered junior`); + throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); + } - const existingUser = await this.userService.findUser({ googleId: payload?.sub }); + if (!existingUser) { + this.logger.debug(`User with google id ${sub} not found, creating new user`); + const user = await this.userService.createGoogleUser(sub, email); - if (!existingUser) { - this.logger.debug(`User with google id ${payload?.sub} not found, creating new user`); - const user = await this.userService.createGoogleUser(payload!.sub, payload!.email!); + const tokens = await this.generateAuthToken(user); - const tokens = await this.generateAuthToken(user); + return [tokens, user]; + } - return [tokens, user]; + const tokens = await this.generateAuthToken(existingUser); + + return [tokens, existingUser]; + } + + private async loginWithApple(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { + const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken); + + const [existingUser, isJunior] = await Promise.all([ + this.userService.findUser({ appleId: sub }), + this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), + ]); + + if (isJunior && email) { + this.logger.error(`User with email ${email} is an already registered junior`); + throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); + } + + if (!existingUser) { + // Apple only provides email if user authorized zod for the first time + if (!email) { + this.logger.error(`User authorized zod before but his email is not stored in the database`); + throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED'); } - const tokens = await this.generateAuthToken(existingUser); + this.logger.debug(`User with apple id ${sub} not found, creating new user`); + const user = await this.userService.createAppleUser(sub, email); - return [tokens, existingUser]; - } catch (error) { - this.logger.error(`Invalid google token`, error); - throw new UnauthorizedException(); + const tokens = await this.generateAuthToken(user); + + return [tokens, user]; } + + const tokens = await this.generateAuthToken(existingUser); + + this.logger.log(`User with apple id ${sub} logged in successfully`); + + return [tokens, existingUser]; } private async generateAuthToken(user: User) { diff --git a/src/auth/services/index.ts b/src/auth/services/index.ts index 2a719d1..69c39b4 100644 --- a/src/auth/services/index.ts +++ b/src/auth/services/index.ts @@ -1 +1,2 @@ export * from './auth.service'; +export * from './oauth2.service'; diff --git a/src/auth/services/oauth2.service.ts b/src/auth/services/oauth2.service.ts new file mode 100644 index 0000000..0af8b39 --- /dev/null +++ b/src/auth/services/oauth2.service.ts @@ -0,0 +1,83 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { OAuth2Client } from 'google-auth-library'; +import jwkToPem from 'jwk-to-pem'; +import { lastValueFrom } from 'rxjs'; +import { ApplePayload } from '../interfaces'; + +@Injectable() +export class Oauth2Service { + private readonly logger = new Logger(Oauth2Service.name); + private appleKeysEndpoint = 'https://appleid.apple.com/auth/keys'; + private appleIssuer = 'https://appleid.apple.com'; + private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID'); + private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID'); + private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID'); + private readonly client = new OAuth2Client(); + constructor( + private readonly httpService: HttpService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async verifyAppleToken(appleToken: string): Promise { + try { + const response = await lastValueFrom(this.httpService.get(this.appleKeysEndpoint)); + + const keys = response.data.keys; + + const decodedHeader = this.jwtService.decode(appleToken, { complete: true })?.header; + + if (!decodedHeader) { + this.logger.error(`Invalid apple token`); + throw new UnauthorizedException(); + } + + const keyId = decodedHeader.kid; + + const appleKey = keys.find((key: any) => key.kid === keyId); + + if (!appleKey) { + this.logger.error(`Invalid apple token`); + throw new UnauthorizedException(); + } + + const publicKey = jwkToPem(appleKey); + + const payload = this.jwtService.verify(appleToken, { + publicKey, + algorithms: ['RS256'], + audience: this.configService.getOrThrow('APPLE_CLIENT_ID'), + issuer: this.appleIssuer, + }); + + return payload; + } catch (error) { + this.logger.error(`Error verifying apple token: ${error} `); + throw new UnauthorizedException(error); + } + } + + async verifyGoogleToken(googleToken: string): Promise { + try { + const ticket = await this.client.verifyIdToken({ + idToken: googleToken, + audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId], + }); + + const payload = ticket.getPayload(); + + if (!payload) { + this.logger.error(`payload not found in google token`); + throw new UnauthorizedException(); + } + + return payload; + } catch (error) { + this.logger.error(`Invalid google token`, error); + throw new UnauthorizedException(); + } + } +} diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index f76437c..65837a2 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -87,7 +87,27 @@ export class UserService { @Transactional() async createGoogleUser(googleId: string, email: string) { this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`); - const user = await this.userRepository.createUser({ googleId, email, roles: [Roles.GUARDIAN] }); + const user = await this.userRepository.createUser({ + googleId, + email, + roles: [Roles.GUARDIAN], + isEmailVerified: true, + }); + + await this.customerService.createGuardianCustomer(user.id); + + return this.findUserOrThrow({ id: user.id }); + } + + @Transactional() + async createAppleUser(appleId: string, email: string) { + this.logger.log(`Creating apple user with appleId ${appleId} and email ${email}`); + const user = await this.userRepository.createUser({ + appleId, + email, + roles: [Roles.GUARDIAN], + isEmailVerified: true, + }); await this.customerService.createGuardianCustomer(user.id);