feat: add apple login

This commit is contained in:
Abdalhamid Alhamad
2025-01-16 12:17:10 +03:00
parent 663e8972c4
commit 87bb1a2709
10 changed files with 275 additions and 28 deletions

92
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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: [],
})

View File

@ -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' }) })

View File

@ -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;
}

View File

@ -1,2 +1,3 @@
export * from './apple-payload.interface';
export * from './jwt-payload.interface';
export * from './login-response.interface';

View File

@ -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) {

View File

@ -1 +1,2 @@
export * from './auth.service';
export * from './oauth2.service';

View File

@ -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<ApplePayload> {
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<any> {
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();
}
}
}

View File

@ -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);