From 0391bf65f2e104a34ba660e0d08371e47b6062d3 Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Wed, 27 Mar 2024 20:18:07 +0000 Subject: [PATCH] feat: Oauth with PKCE (#4648) * authorizeApp and exchangeAuthcode methods * module rename * import fix * lint fix * fix import --- packages/twenty-server/.env.example | 1 + .../engine/core-modules/auth/auth.resolver.ts | 24 +++++++ .../auth/dto/authorize-app.entity.ts | 7 ++ .../auth/dto/authorize-app.input.ts | 10 +++ .../auth/dto/exchange-auth-code.entity.ts | 15 ++++ .../auth/dto/exchange-auth-code.input.ts | 10 +++ .../auth/services/auth.service.ts | 37 ++++++++++ .../auth/services/token.service.ts | 68 +++++++++++++++++++ .../environment/environment-variables.ts | 2 + 9 files changed, 174 insertions(+) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.input.ts diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 186fceee095c..d1f872cce611 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -62,3 +62,4 @@ SIGN_IN_PREFILLED=true # API_RATE_LIMITING_TTL= # API_RATE_LIMITING_LIMIT= # MUTATION_MAXIMUM_RECORD_AFFECTED=100 +# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapps.com diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 6cd86e1354b4..32cfeb05744c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -27,6 +27,10 @@ import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate- import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input'; import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity'; +import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; +import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; +import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ApiKeyToken, AuthTokens } from './dto/token.entity'; import { TokenService } from './services/token.service'; @@ -131,6 +135,26 @@ export class AuthResolver { return result; } + @Mutation(() => AuthorizeApp) + @UseGuards(JwtAuthGuard) + authorizeApp(@Args() authorizeAppInput: AuthorizeAppInput): AuthorizeApp { + const authorizedApp = + this.authService.generateAuthorizationCode(authorizeAppInput); + + return authorizedApp; + } + + @Query(() => ExchangeAuthCode) + async exchangeAuthorizationCode( + @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, + ) { + const tokens = await this.tokenService.verifyAuthorizationCode( + exchangeAuthCodeInput, + ); + + return tokens; + } + @Mutation(() => AuthTokens) @UseGuards(JwtAuthGuard) async generateJWT( diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.entity.ts new file mode 100644 index 000000000000..9407e77816ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.entity.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class AuthorizeApp { + @Field(() => String) + redirectUrl: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.input.ts new file mode 100644 index 000000000000..3bd1b25349fa --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/authorize-app.input.ts @@ -0,0 +1,10 @@ +import { Field, ArgsType } from '@nestjs/graphql'; + +@ArgsType() +export class AuthorizeAppInput { + @Field(() => String) + clientId: string; + + @Field(() => String) + codeChallenge: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.entity.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.entity.ts new file mode 100644 index 000000000000..cc2be5145773 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.entity.ts @@ -0,0 +1,15 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; + +@ObjectType() +export class ExchangeAuthCode { + @Field(() => AuthToken) + accessToken: AuthToken; + + @Field(() => AuthToken) + refreshToken: AuthToken; + + @Field(() => AuthToken) + loginToken: AuthToken; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.input.ts new file mode 100644 index 000000000000..3ff5e67dd43e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/exchange-auth-code.input.ts @@ -0,0 +1,10 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +@ArgsType() +export class ExchangeAuthCodeInput { + @Field(() => String) + authorizationCode: string; + + @Field(() => String) + codeVerifier: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 11d6c1d63e26..e9bfe83c4a30 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -6,6 +6,8 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import crypto from 'node:crypto'; + import { Repository } from 'typeorm'; import { render } from '@react-email/components'; import { PasswordUpdateNotifyEmail } from 'twenty-emails'; @@ -27,6 +29,8 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm import { EmailService } from 'src/engine/integrations/email/email.service'; import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity'; import { SignUpService } from 'src/engine/core-modules/auth/services/sign-up.service'; +import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input'; +import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity'; import { TokenService } from './token.service'; @@ -173,6 +177,39 @@ export class AuthService { }; } + generateAuthorizationCode( + authorizeAppInput: AuthorizeAppInput, + ): AuthorizeApp { + // TODO: replace with db call to - third party app table + const apps = [ + { + id: 'chrome', + name: 'Chrome Extension', + redirectUrl: `${this.environmentService.get( + 'CHROME_EXTENSION_REDIRECT_URL', + )}`, + }, + ]; + + const { clientId } = authorizeAppInput; + + const client = apps.find((app) => app.id === clientId); + + if (!client) { + throw new NotFoundException(`Invalid client '${clientId}'`); + } + + const authorizationCode = crypto.randomBytes(42).toString('hex'); + + // const expiresAt = addMilliseconds(new Date().getTime(), ms('5m')); + + //TODO: DB call to save - (userId, codeChallenge, authorizationCode, expiresAt) + + const redirectUrl = `${client.redirectUrl}?authorizationCode=${authorizationCode}`; + + return { redirectUrl }; + } + async updatePassword( userId: string, newPassword: string, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts index 2ab049d23b81..f922380ad6a7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/token.service.ts @@ -41,6 +41,9 @@ import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate- import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; import { JwtData } from 'src/engine/core-modules/auth/types/jwt-data.type'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; +import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; +import { DEV_SEED_USER_IDS } from 'src/database/typeorm-seeds/core/users'; @Injectable() export class TokenService { @@ -281,6 +284,71 @@ export class TokenService { }; } + async verifyAuthorizationCode( + exchangeAuthCodeInput: ExchangeAuthCodeInput, + ): Promise { + const { authorizationCode, codeVerifier } = exchangeAuthCodeInput; + + assert( + authorizationCode, + 'Authorization code not found', + NotFoundException, + ); + + assert(codeVerifier, 'code verifier not found', NotFoundException); + + // TODO: replace this with call to stateless table + + // assert(authObj, 'Authorization code does not exist', NotFoundException); + + // assert( + // authObj.expiresAt.getTime() <= Date.now(), + // 'Authorization code expired.', + // NotFoundException, + // ); + + // const codeChallenge = crypto + // .createHash('sha256') + // .update(codeVerifier) + // .digest() + // .toString('base64') + // .replace(/\+/g, '-') + // .replace(/\//g, '_') + // .replace(/=/g, ''); + + // assert( + // authObj.codeChallenge !== codeChallenge, + // 'code verifier doesnt match the challenge', + // ForbiddenException, + // ); + + const user = await this.userRepository.findOne({ + where: { id: DEV_SEED_USER_IDS.TIM }, // TODO: replace this id with corresponding authenticated user id mappeed to authorization code + relations: ['defaultWorkspace'], + }); + + if (!user) { + throw new NotFoundException('User is not found'); + } + + if (!user.defaultWorkspace) { + throw new NotFoundException('User does not have a default workspace'); + } + + const accessToken = await this.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.generateRefreshToken(user.id); + const loginToken = await this.generateLoginToken(user.email); + + return { + accessToken, + refreshToken, + loginToken, + }; + } + async verifyRefreshToken(refreshToken: string) { const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); diff --git a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts index 452c643352b9..227f0d38ac95 100644 --- a/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/integrations/environment/environment-variables.ts @@ -303,6 +303,8 @@ export class EnvironmentVariables { CALENDAR_PROVIDER_GOOGLE_ENABLED: boolean = false; AUTH_GOOGLE_APIS_CALLBACK_URL: string; + + CHROME_EXTENSION_REDIRECT_URL: string; } export const validate = (config: Record) => {