mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-08-26 06:09:41 +00:00
feat: add apple login
This commit is contained in:
92
package-lock.json
generated
92
package-lock.json
generated
@ -37,6 +37,7 @@
|
|||||||
"google-libphonenumber": "^3.2.39",
|
"google-libphonenumber": "^3.2.39",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"jwk-to-pem": "^2.0.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nestjs-i18n": "^10.4.9",
|
"nestjs-i18n": "^10.4.9",
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/google-libphonenumber": "^7.4.30",
|
"@types/google-libphonenumber": "^7.4.30",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/jwk-to-pem": "^2.0.3",
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
@ -2858,6 +2860,13 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.13",
|
"version": "4.17.13",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -3788,6 +3797,18 @@
|
|||||||
"safer-buffer": "~2.1.0"
|
"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": {
|
"node_modules/assert-never": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -4055,6 +4076,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -4155,6 +4182,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.24.2",
|
"version": "4.24.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -5514,6 +5547,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/emitter-listener": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
@ -6994,6 +7042,16 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -7031,6 +7089,17 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/hookified": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@ -8503,6 +8572,17 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/jwks-rsa": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz",
|
||||||
@ -9532,6 +9612,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"google-libphonenumber": "^3.2.39",
|
"google-libphonenumber": "^3.2.39",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"jwk-to-pem": "^2.0.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nestjs-i18n": "^10.4.9",
|
"nestjs-i18n": "^10.4.9",
|
||||||
@ -82,6 +83,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/google-libphonenumber": "^7.4.30",
|
"@types/google-libphonenumber": "^7.4.30",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/jwk-to-pem": "^2.0.3",
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { JuniorModule } from '~/junior/junior.module';
|
import { JuniorModule } from '~/junior/junior.module';
|
||||||
import { UserModule } from '~/user/user.module';
|
import { UserModule } from '~/user/user.module';
|
||||||
import { AuthController } from './controllers';
|
import { AuthController } from './controllers';
|
||||||
import { AuthService } from './services';
|
import { AuthService, Oauth2Service } from './services';
|
||||||
import { AccessTokenStrategy } from './strategies';
|
import { AccessTokenStrategy } from './strategies';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), JuniorModule, UserModule],
|
imports: [JwtModule.register({}), JuniorModule, UserModule, HttpModule],
|
||||||
providers: [AuthService, AccessTokenStrategy],
|
providers: [AuthService, AccessTokenStrategy, Oauth2Service],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
|
@ -29,6 +29,12 @@ export class LoginRequestDto {
|
|||||||
@ValidateIf((o) => o.grantType === GrantType.GOOGLE)
|
@ValidateIf((o) => o.grantType === GrantType.GOOGLE)
|
||||||
googleToken!: string;
|
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' })
|
@ApiProperty({ example: 'fcm-device-token' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
|
||||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) })
|
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) })
|
||||||
|
11
src/auth/interfaces/apple-payload.interface.ts
Normal file
11
src/auth/interfaces/apple-payload.interface.ts
Normal 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;
|
||||||
|
}
|
@ -1,2 +1,3 @@
|
|||||||
|
export * from './apple-payload.interface';
|
||||||
export * from './jwt-payload.interface';
|
export * from './jwt-payload.interface';
|
||||||
export * from './login-response.interface';
|
export * from './login-response.interface';
|
||||||
|
@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
import { ArrayContains } from 'typeorm';
|
||||||
import { CacheService } from '~/common/modules/cache/services';
|
import { CacheService } from '~/common/modules/cache/services';
|
||||||
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
||||||
import { OtpService } from '~/common/modules/otp/services';
|
import { OtpService } from '~/common/modules/otp/services';
|
||||||
@ -22,19 +22,16 @@ import {
|
|||||||
setJuniorPasswordRequestDto,
|
setJuniorPasswordRequestDto,
|
||||||
VerifyUserRequestDto,
|
VerifyUserRequestDto,
|
||||||
} from '../dtos/request';
|
} from '../dtos/request';
|
||||||
import { GrantType } from '../enums';
|
import { GrantType, Roles } from '../enums';
|
||||||
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
||||||
import { removePadding, verifySignature } from '../utils';
|
import { removePadding, verifySignature } from '../utils';
|
||||||
|
import { Oauth2Service } from './oauth2.service';
|
||||||
|
|
||||||
const ONE_THOUSAND = 1000;
|
const ONE_THOUSAND = 1000;
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
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(
|
constructor(
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
@ -44,6 +41,7 @@ export class AuthService {
|
|||||||
private readonly deviceService: DeviceService,
|
private readonly deviceService: DeviceService,
|
||||||
private readonly juniorTokenService: JuniorTokenService,
|
private readonly juniorTokenService: JuniorTokenService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly oauth2Service: Oauth2Service,
|
||||||
) {}
|
) {}
|
||||||
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
||||||
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
|
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
|
||||||
@ -269,7 +267,7 @@ export class AuthService {
|
|||||||
|
|
||||||
if (loginDto.grantType === GrantType.APPLE) {
|
if (loginDto.grantType === GrantType.APPLE) {
|
||||||
this.logger.log(`Logging in user with email ${loginDto.email} using 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) {
|
if (loginDto.grantType === GrantType.PASSWORD) {
|
||||||
@ -383,19 +381,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
private async loginWithGoogle(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
||||||
try {
|
const { email, sub } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
|
||||||
const ticket = await this.client.verifyIdToken({
|
const [existingUser, isJunior] = await Promise.all([
|
||||||
idToken: loginDto.googleToken,
|
this.userService.findUser({ googleId: sub }),
|
||||||
audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId],
|
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`);
|
||||||
const existingUser = await this.userService.findUser({ googleId: payload?.sub });
|
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
|
||||||
|
}
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
this.logger.debug(`User with google id ${payload?.sub} not found, creating new user`);
|
this.logger.debug(`User with google id ${sub} not found, creating new user`);
|
||||||
const user = await this.userService.createGoogleUser(payload!.sub, payload!.email!);
|
const user = await this.userService.createGoogleUser(sub, email);
|
||||||
|
|
||||||
const tokens = await this.generateAuthToken(user);
|
const tokens = await this.generateAuthToken(user);
|
||||||
|
|
||||||
@ -405,10 +404,41 @@ export class AuthService {
|
|||||||
const tokens = await this.generateAuthToken(existingUser);
|
const tokens = await this.generateAuthToken(existingUser);
|
||||||
|
|
||||||
return [tokens, existingUser];
|
return [tokens, existingUser];
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Invalid google token`, error);
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
|
||||||
|
const user = await this.userService.createAppleUser(sub, email);
|
||||||
|
|
||||||
|
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) {
|
private async generateAuthToken(user: User) {
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
|
export * from './oauth2.service';
|
||||||
|
83
src/auth/services/oauth2.service.ts
Normal file
83
src/auth/services/oauth2.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -87,7 +87,27 @@ export class UserService {
|
|||||||
@Transactional()
|
@Transactional()
|
||||||
async createGoogleUser(googleId: string, email: string) {
|
async createGoogleUser(googleId: string, email: string) {
|
||||||
this.logger.log(`Creating google user with googleId ${googleId} and email ${email}`);
|
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);
|
await this.customerService.createGuardianCustomer(user.id);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user