From f9eed54ffb0abf33e7b235642c1499de457385b2 Mon Sep 17 00:00:00 2001 From: ZiaCodes Date: Fri, 18 Oct 2024 10:05:39 +0500 Subject: [PATCH 01/17] feat: generate secret function and replaced few instances --- .../auth/token/services/token.service.ts | 5 +++-- .../twenty-server/src/utils/generate-secret.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/utils/generate-secret.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index c740cb9a0613..927d2f7c2cd1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -46,6 +46,7 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { generateSecret } from 'src/utils/generate-secret'; @Injectable() export class TokenService { @@ -232,7 +233,7 @@ export class TokenService { userId: string, workspaceId: string, ): Promise { - const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const secret = generateSecret(workspaceId, 'LOGIN'); const expiresIn = this.environmentService.get( 'SHORT_TERM_TOKEN_EXPIRES_IN', ); @@ -271,7 +272,7 @@ export class TokenService { const jwtPayload = { sub: workspaceId, }; - const secret = this.environmentService.get('ACCESS_TOKEN_SECRET'); + const secret = generateSecret(workspaceId, 'ACCESS'); let expiresIn: string | number; if (expiresAt) { diff --git a/packages/twenty-server/src/utils/generate-secret.ts b/packages/twenty-server/src/utils/generate-secret.ts new file mode 100644 index 000000000000..bfec58483a77 --- /dev/null +++ b/packages/twenty-server/src/utils/generate-secret.ts @@ -0,0 +1,14 @@ +import { createHash } from 'crypto'; + +if (!process.env.APP_SECRET) { + throw new Error('APP_SECRET is not set'); +} + +export const generateSecret = ( + workspaceId: string, + type: 'ACCESS' | 'LOGIN' | 'REFRESH' | 'FILE', +): string => { + return createHash('sha256') + .update(`${process.env.APP_SECRET}${workspaceId}${type}`) + .digest('hex'); +}; From a979e8244606cee1ae8c39a925c1a1ba31849191 Mon Sep 17 00:00:00 2001 From: ZiaCodes Date: Sat, 19 Oct 2024 15:45:07 +0500 Subject: [PATCH 02/17] feat: use environment service to get app secret --- .../environment/environment-variables.ts | 7 +++++++ .../twenty-server/src/utils/generate-secret.ts | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index cb5b2fbe2822..9a3d42d81af6 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -126,14 +126,19 @@ export class EnvironmentVariables { SERVER_URL: string; // Json Web Token + // TODO: Remove @IsString() ACCESS_TOKEN_SECRET: string; + @IsString() + APP_SECRET: string; + @IsDuration() @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; @IsString() + // TODO: Remove REFRESH_TOKEN_SECRET: string; @IsDuration() @@ -145,6 +150,7 @@ export class EnvironmentVariables { REFRESH_TOKEN_COOL_DOWN = '1m'; @IsString() + // TODO: Remove LOGIN_TOKEN_SECRET = '30m'; @IsDuration() @@ -153,6 +159,7 @@ export class EnvironmentVariables { @IsString() @IsOptional() + // TODO: Remove FILE_TOKEN_SECRET = 'random_string'; @IsDuration() diff --git a/packages/twenty-server/src/utils/generate-secret.ts b/packages/twenty-server/src/utils/generate-secret.ts index bfec58483a77..e00ac77c809c 100644 --- a/packages/twenty-server/src/utils/generate-secret.ts +++ b/packages/twenty-server/src/utils/generate-secret.ts @@ -1,14 +1,22 @@ +import { ConfigService } from '@nestjs/config'; + import { createHash } from 'crypto'; -if (!process.env.APP_SECRET) { - throw new Error('APP_SECRET is not set'); -} +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; export const generateSecret = ( workspaceId: string, type: 'ACCESS' | 'LOGIN' | 'REFRESH' | 'FILE', ): string => { + const appSecret = new EnvironmentService(new ConfigService()).get( + 'APP_SECRET', + ); + + if (!appSecret) { + throw new Error('APP_SECRET is not set'); + } + return createHash('sha256') - .update(`${process.env.APP_SECRET}${workspaceId}${type}`) + .update(`${appSecret}${workspaceId}${type}`) .digest('hex'); }; From 09f7b5dbd490dc1ad3169114cb17ad48e1531fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 20 Oct 2024 16:04:07 +0200 Subject: [PATCH 03/17] Replace variables --- .github/workflows/ci-test-docker-compose.yaml | 5 +-- install.sh | 5 +-- packages/twenty-docker/.env.example | 5 +-- packages/twenty-docker/docker-compose.yml | 10 ++---- .../k8s/manifests/deployment-server.yaml | 17 +--------- .../k8s/manifests/deployment-worker.yaml | 17 +--------- .../k8s/terraform/deployment-server.tf | 32 +------------------ .../k8s/terraform/deployment-worker.tf | 32 +------------------ packages/twenty-server/.env.example | 5 +-- packages/twenty-server/.env.test | 4 +-- .../auth/token/services/token.service.ts | 12 +++---- .../environment/environment-variables.ts | 19 ----------- .../file/guards/file-path-guard.ts | 4 +-- .../file/services/file.service.ts | 4 +-- .../src/engine/core-modules/jwt/jwt.module.ts | 4 +-- .../jwt/services/jwt-wrapper.service.ts | 30 ++++++++++++++++- .../postgres-credentials.module.ts | 7 ++-- .../postgres-credentials.service.ts | 10 +++--- .../remote-server/remote-server.service.ts | 30 ++++++++--------- .../self-hosting/cloud-providers.mdx | 23 +++---------- .../self-hosting/docker-compose.mdx | 12 +++---- .../self-hosting/self-hosting-var.mdx | 5 +-- render.yaml | 16 ++-------- 23 files changed, 86 insertions(+), 222 deletions(-) diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index 1496425c8511..5487f3a587b3 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -31,10 +31,7 @@ jobs: cp .env.example .env echo "Generating secrets..." echo "# === Randomly generated secrets ===" >>.env - echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env + echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env echo "Starting server..." diff --git a/install.sh b/install.sh index 39eb096b8e25..7c43d56616b3 100755 --- a/install.sh +++ b/install.sh @@ -91,10 +91,7 @@ fi # Generate random strings for secrets echo "# === Randomly generated secrets ===" >>.env -echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env +echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index 59d8d03f93a7..e0248fcc203d 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -9,10 +9,7 @@ SERVER_URL=http://localhost:3000 # REDIS_PORT=6379 # Use openssl rand -base64 32 for each secret -# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -# LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -# REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh -# FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh +# APP_SECRET=replace_me_with_a_random_string_access SIGN_IN_PREFILLED=true diff --git a/packages/twenty-docker/docker-compose.yml b/packages/twenty-docker/docker-compose.yml index b2efc1a168e4..e5eadf3de446 100644 --- a/packages/twenty-docker/docker-compose.yml +++ b/packages/twenty-docker/docker-compose.yml @@ -36,10 +36,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: change-vol-ownership: condition: service_completed_successfully @@ -69,10 +66,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: db: condition: service_healthy diff --git a/packages/twenty-docker/k8s/manifests/deployment-server.yaml b/packages/twenty-docker/k8s/manifests/deployment-server.yaml index b1229d649bbb..317e931af3db 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-server.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-server.yaml @@ -57,26 +57,11 @@ spec: value: "7d" - name: "LOGIN_TOKEN_EXPIRES_IN" value: "1h" - - name: ACCESS_TOKEN_SECRET + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken ports: - containerPort: 3000 name: http-tcp diff --git a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml index b3a7e07a19aa..617d05636b2b 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml @@ -44,26 +44,11 @@ spec: value: "twentycrm-redis.twentycrm.svc.cluster.local" - name: "REDIS_PORT" value: 6379 - - name: ACCESS_TOKEN_SECRET + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken command: - yarn - worker:prod diff --git a/packages/twenty-docker/k8s/terraform/deployment-server.tf b/packages/twenty-docker/k8s/terraform/deployment-server.tf index 1868b17624da..0bb264ef39d8 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-server.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-server.tf @@ -95,7 +95,7 @@ resource "kubernetes_deployment" "twentycrm_server" { value = "1h" } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -104,36 +104,6 @@ resource "kubernetes_deployment" "twentycrm_server" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - port { container_port = 3000 protocol = "TCP" diff --git a/packages/twenty-docker/k8s/terraform/deployment-worker.tf b/packages/twenty-docker/k8s/terraform/deployment-worker.tf index 78e5ea6dcc1d..aa802e839929 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-worker.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-worker.tf @@ -83,7 +83,7 @@ resource "kubernetes_deployment" "twentycrm_worker" { } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -92,36 +92,6 @@ resource "kubernetes_deployment" "twentycrm_worker" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - resources { requests = { cpu = "250m" diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 6f8167d6b869..b591e8a55b61 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -3,10 +3,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default FRONT_BASE_URL=http://localhost:3001 -ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh -FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh +APP_SECRET=replace_me_with_a_random_string_app SIGN_IN_PREFILLED=true # ———————— Optional ———————— diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index ed0c63d78337..300ea35c35ef 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -3,9 +3,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test DEBUG_MODE=true DEBUG_PORT=9000 FRONT_BASE_URL=http://localhost:3001 -ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh +APP_SECRET=replace_me_with_a_random_string_access SIGN_IN_PREFILLED=true EXCEPTION_HANDLER_DRIVER=console SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index 927d2f7c2cd1..d6c200e6c547 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -140,7 +140,7 @@ export class TokenService { } async generateRefreshToken(userId: string): Promise { - const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); + const secret = this.jwtWrapperService.generateAppSecret('REFRESH'); const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); if (!expiresIn) { @@ -204,7 +204,7 @@ export class TokenService { } async generateLoginToken(email: string): Promise { - const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); if (!expiresIn) { @@ -308,7 +308,7 @@ export class TokenService { } const decoded = await this.verifyJwt( token, - this.environmentService.get('ACCESS_TOKEN_SECRET'), + this.jwtWrapperService.generateAppSecret('ACCESS'), ); const { user, apiKey, workspace, workspaceMemberId } = @@ -318,7 +318,7 @@ export class TokenService { } async verifyLoginToken(loginToken: string): Promise { - const loginTokenSecret = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const loginTokenSecret = this.jwtWrapperService.generateAppSecret('LOGIN'); const payload = await this.verifyJwt(loginToken, loginTokenSecret); @@ -331,7 +331,7 @@ export class TokenService { workspaceId: string; }> { const transientTokenSecret = - this.environmentService.get('LOGIN_TOKEN_SECRET'); + this.jwtWrapperService.generateAppSecret('LOGIN'); const payload = await this.verifyJwt(transientToken, transientTokenSecret); @@ -514,7 +514,7 @@ export class TokenService { } async verifyRefreshToken(refreshToken: string) { - const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); + const secret = this.jwtWrapperService.generateAppSecret('REFRESH'); const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); const jwtPayload = await this.verifyJwt(refreshToken, secret); diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 9a3d42d81af6..1153894cabbe 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -125,11 +125,6 @@ export class EnvironmentVariables { @IsOptional() SERVER_URL: string; - // Json Web Token - // TODO: Remove - @IsString() - ACCESS_TOKEN_SECRET: string; - @IsString() APP_SECRET: string; @@ -137,11 +132,6 @@ export class EnvironmentVariables { @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; - @IsString() - // TODO: Remove - REFRESH_TOKEN_SECRET: string; - - @IsDuration() @IsOptional() REFRESH_TOKEN_EXPIRES_IN = '60d'; @@ -149,19 +139,10 @@ export class EnvironmentVariables { @IsOptional() REFRESH_TOKEN_COOL_DOWN = '1m'; - @IsString() - // TODO: Remove - LOGIN_TOKEN_SECRET = '30m'; - @IsDuration() @IsOptional() LOGIN_TOKEN_EXPIRES_IN = '15m'; - @IsString() - @IsOptional() - // TODO: Remove - FILE_TOKEN_SECRET = 'random_string'; - @IsDuration() @IsOptional() FILE_TOKEN_EXPIRES_IN = '1d'; diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 890d060dd84b..8677d45ba5f5 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -6,8 +6,8 @@ import { Injectable, } from '@nestjs/common'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @Injectable() export class FilePathGuard implements CanActivate { @@ -25,7 +25,7 @@ export class FilePathGuard implements CanActivate { const decodedPayload = await this.jwtWrapperService.decode( payloadToDecode, { - secret: this.environmentService.get('FILE_TOKEN_SECRET'), + secret: this.jwtWrapperService.generateAppSecret('FILE'), } as any, ); diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index a1c59e700806..acfc6cbf321b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -5,9 +5,9 @@ import { Stream } from 'stream'; import { addMilliseconds } from 'date-fns'; import ms from 'ms'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @Injectable() export class FileService { @@ -34,7 +34,7 @@ export class FileService { const fileTokenExpiresIn = this.environmentService.get( 'FILE_TOKEN_EXPIRES_IN', ); - const secret = this.environmentService.get('FILE_TOKEN_SECRET'); + const secret = this.jwtWrapperService.generateAppSecret('FILE'); const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn)); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts index 306f1640e008..8a689819cbd6 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/jwt.module.ts @@ -2,14 +2,14 @@ import { Module } from '@nestjs/common'; import { JwtModule as NestJwtModule } from '@nestjs/jwt'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; const InternalJwtModule = NestJwtModule.registerAsync({ useFactory: async (environmentService: EnvironmentService) => { return { - secret: environmentService.get('ACCESS_TOKEN_SECRET'), + secret: environmentService.get('APP_SECRET'), signOptions: { expiresIn: environmentService.get('ACCESS_TOKEN_EXPIRES_IN'), }, diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 79ebee2c86b4..45bcb56c703a 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -1,11 +1,18 @@ import { Injectable } from '@nestjs/common'; import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; +import { createHash } from 'crypto'; + import * as jwt from 'jsonwebtoken'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; + @Injectable() export class JwtWrapperService { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private readonly environmentService: EnvironmentService, + ) {} sign(payload: string | object, options?: JwtSignOptions): string { // Typescript does not handle well the overloads of the sign method, helping it a little bit @@ -23,4 +30,25 @@ export class JwtWrapperService { decode(payload: string, options: jwt.DecodeOptions): T { return this.jwtService.decode(payload, options); } + + generateAppSecret( + type: + | 'ACCESS' + | 'LOGIN' + | 'REFRESH' + | 'FILE' + | 'POSTGRES_PROXY' + | 'REMOTE_SERVER', + workspaceId?: string, + ): string { + const appSecret = this.environmentService.get('APP_SECRET'); + + if (!appSecret) { + throw new Error('APP_SECRET is not set'); + } + + return createHash('sha256') + .update(`${appSecret}${workspaceId}${type}`) + .digest('hex'); + } } diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts index 9034a9a1bfb3..48bb30c0b7c1 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.module.ts @@ -1,16 +1,13 @@ import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver'; import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service'; -import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module'; @Module({ - imports: [ - TypeOrmModule.forFeature([PostgresCredentials], 'core'), - EnvironmentModule, - ], + imports: [TypeOrmModule.forFeature([PostgresCredentials], 'core'), JwtModule], providers: [ PostgresCredentialsResolver, PostgresCredentialsService, diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts index de8e78244d37..1aa54eb57843 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts @@ -9,16 +9,18 @@ import { decryptText, encryptText, } from 'src/engine/core-modules/auth/auth.util'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; export class PostgresCredentialsService { constructor( @InjectRepository(PostgresCredentials, 'core') private readonly postgresCredentialsRepository: Repository, private readonly environmentService: EnvironmentService, + private readonly jwtWrapperService: JwtWrapperService, ) {} async enablePostgresProxy( @@ -27,7 +29,7 @@ export class PostgresCredentialsService { const user = `user_${randomBytes(4).toString('hex')}`; const password = randomBytes(16).toString('hex'); - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); const passwordHash = encryptText(password, key); const existingCredentials = @@ -81,7 +83,7 @@ export class PostgresCredentialsService { id: postgresCredentials.id, }); - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); return { id: postgresCredentials.id, @@ -105,7 +107,7 @@ export class PostgresCredentialsService { return null; } - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); return { id: postgresCredentials.id, diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts index 8538eabcb41d..82a3b03774c4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts @@ -2,31 +2,31 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import isEmpty from 'lodash.isempty'; -import { v4 } from 'uuid'; import { DataSource, EntityManager, Repository } from 'typeorm'; +import { v4 } from 'uuid'; +import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory'; +import { encryptText } from 'src/engine/core-modules/auth/auth.util'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { CreateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/create-remote-server.input'; +import { UpdateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/update-remote-server.input'; import { RemoteServerEntity, RemoteServerType, } from 'src/engine/metadata-modules/remote-server/remote-server.entity'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { encryptText } from 'src/engine/core-modules/auth/auth.util'; +import { + RemoteServerException, + RemoteServerExceptionCode, +} from 'src/engine/metadata-modules/remote-server/remote-server.exception'; +import { RemoteTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.service'; +import { buildUpdateRemoteServerRawQuery } from 'src/engine/metadata-modules/remote-server/utils/build-update-remote-server-raw-query.utils'; import { validateObjectAgainstInjections, validateStringAgainstInjections, } from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.utils'; -import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory'; -import { RemoteTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.service'; -import { UpdateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/update-remote-server.input'; -import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; -import { buildUpdateRemoteServerRawQuery } from 'src/engine/metadata-modules/remote-server/utils/build-update-remote-server-raw-query.utils'; import { validateRemoteServerType } from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-type.util'; -import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; -import { - RemoteServerException, - RemoteServerExceptionCode, -} from 'src/engine/metadata-modules/remote-server/remote-server.exception'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; @Injectable() export class RemoteServerService { @@ -37,7 +37,7 @@ export class RemoteServerService { >, @InjectDataSource('metadata') private readonly metadataDataSource: DataSource, - private readonly environmentService: EnvironmentService, + private readonly jwtWrapperService: JwtWrapperService, private readonly foreignDataWrapperServerQueryFactory: ForeignDataWrapperServerQueryFactory, private readonly remoteTableService: RemoteTableService, private readonly workspaceDataSourceService: WorkspaceDataSourceService, @@ -253,7 +253,7 @@ export class RemoteServerService { } private encryptPassword(password: string) { - const key = this.environmentService.get('LOGIN_TOKEN_SECRET'); + const key = this.jwtWrapperService.generateAppSecret('REMOTE_SERVER'); return encryptText(password, key); } diff --git a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx index 08e541a21a19..74ace96a250f 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx @@ -271,11 +271,8 @@ resource "azapi_update_resource" "cors" { ```hcl # backend.tf -# Create three random UUIDs -resource "random_uuid" "access_token_secret" {} -resource "random_uuid" "login_token_secret" {} -resource "random_uuid" "refresh_token_secret" {} -resource "random_uuid" "file_token_secret" {} +# Create a random UUIDs +resource "random_uuid" "app_secret" {} resource "azurerm_container_app" "twenty_server" { name = local.server_name @@ -343,20 +340,8 @@ resource "azurerm_container_app" "twenty_server" { value = "https://${local.front_app_name}" } env { - name = "ACCESS_TOKEN_SECRET" - value = random_uuid.access_token_secret.result - } - env { - name = "LOGIN_TOKEN_SECRET" - value = random_uuid.login_token_secret.result - } - env { - name = "REFRESH_TOKEN_SECRET" - value = random_uuid.refresh_token_secret.result - } - env { - name = "FILE_TOKEN_SECRET" - value = random_uuid.file_token_secret.result + name = "APP_SECRET" + value = random_uuid.app_secret.result } } } diff --git a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx index 833fffbcc43e..9ad780391f82 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx @@ -46,23 +46,19 @@ Follow these steps for a manual setup. 2. **Generate Secret Tokens** - Run the following command four times to generate four unique random strings: + Run the following command to generate a unique random strings: ```bash openssl rand -base64 32 ``` - **Important:** Keep these tokens secure and do not share them. + **Important:** Keep this value secret / do not share it. 3. **Update the `.env`** - Replace the placeholder values in your .env file with the generated tokens: + Replace the placeholder values in your .env file with the generated token: ```ini - ACCESS_TOKEN_SECRET=first_random_string - LOGIN_TOKEN_SECRET=second_random_string - REFRESH_TOKEN_SECRET=third_random_string - FILE_TOKEN_SECRET=fourth_random_string + APP_SECRET=first_random_string ``` - **Note:** Only modify these lines unless instructed otherwise. 4. **Set the Postgres Password** diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index cda5fb3a3d1c..9540bee375d6 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -54,14 +54,11 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ### Tokens ', 'Secret used for the access tokens'], + ['APP_SECRET', '', 'Secret used for encryption across the app'], ['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'], - ['LOGIN_TOKEN_SECRET', '', 'Secret used for the login tokens'], ['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'], - ['REFRESH_TOKEN_SECRET', '', 'Secret used for the refresh tokens'], ['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'], ['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'], - ['FILE_TOKEN_SECRET', '', 'Secret used for the file tokens'], ['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'], ['API_TOKEN_EXPIRES_IN', '1000y', 'Api token expiration time'], ]}> diff --git a/render.yaml b/render.yaml index 580cd4a26cfd..3d31f47f5b0a 100644 --- a/render.yaml +++ b/render.yaml @@ -18,13 +18,7 @@ services: name: server type: web envVarKey: RENDER_EXTERNAL_URL - - key: ACCESS_TOKEN_SECRET - generateValue: true - - key: LOGIN_TOKEN_SECRET - generateValue: true - - key: REFRESH_TOKEN_SECRET - generateValue: true - - key: FILE_TOKEN_SECRET + - key: APP_SECRET generateValue: true - key: PG_DATABASE_HOST fromService: @@ -55,13 +49,7 @@ services: name: server type: web envVarKey: RENDER_EXTERNAL_URL - - key: ACCESS_TOKEN_SECRET - generateValue: true - - key: LOGIN_TOKEN_SECRET - generateValue: true - - key: REFRESH_TOKEN_SECRET - generateValue: true - - key: FILE_TOKEN_SECRET + - key: APP_SECRET generateValue: true - key: PG_DATABASE_HOST fromService: From 40d752eb85ac1b3f180256706e9ec45b1341356a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 20 Oct 2024 16:14:22 +0200 Subject: [PATCH 04/17] Upgrade guide --- .../content/developers/self-hosting/upgrade-guide.mdx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx index 2379fb2fc42e..487e004f786d 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/upgrade-guide.mdx @@ -103,7 +103,7 @@ The `yarn command:prod upgrade-31` takes care of the data migration of all works ### Environment Variables -The following environment variables have been changed: +We have updated the way we handle the Redis connection. - Removed: `REDIS_HOST`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD` - Added: `REDIS_URL` @@ -111,3 +111,10 @@ The following environment variables have been changed: Update your `.env` file to use the new `REDIS_URL` variable instead of the individual Redis connection parameters. + +We have also simplifed the way we handle the JWT tokens. + +- Removed: `ACCESS_TOKEN_SECRET`, `LOGIN_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `FILE_TOKEN_SECRET` +- Added: `APP_SECRET` + +Update your `.env` file to use the new `APP_SECRET` variable instead of the individual tokens secrets (you can use the same secret as before or generate a new random string) From 5477e18ac95cbbf4484e19e237515975c836fcbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 20 Oct 2024 17:07:27 +0200 Subject: [PATCH 05/17] Pass workspaceId for remoteServer and PostgresCredentials --- .../core-modules/file/guards/file-path-guard.ts | 6 +----- .../postgres-credentials.service.ts | 15 ++++++++++++--- .../remote-server/remote-server.service.ts | 9 +++++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 8677d45ba5f5..932bbdffd72d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -6,15 +6,11 @@ import { Injectable, } from '@nestjs/common'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @Injectable() export class FilePathGuard implements CanActivate { - constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly environmentService: EnvironmentService, - ) {} + constructor(private readonly jwtWrapperService: JwtWrapperService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); diff --git a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts index 5dc52373502d..6ae8d5a3d1cf 100644 --- a/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts +++ b/packages/twenty-server/src/engine/core-modules/postgres-credentials/postgres-credentials.service.ts @@ -27,7 +27,10 @@ export class PostgresCredentialsService { const user = `user_${randomBytes(4).toString('hex')}`; const password = randomBytes(16).toString('hex'); - const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); const passwordHash = encryptText(password, key); const existingCredentials = @@ -81,7 +84,10 @@ export class PostgresCredentialsService { id: postgresCredentials.id, }); - const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); return { id: postgresCredentials.id, @@ -105,7 +111,10 @@ export class PostgresCredentialsService { return null; } - const key = this.jwtWrapperService.generateAppSecret('POSTGRES_PROXY'); + const key = this.jwtWrapperService.generateAppSecret( + 'POSTGRES_PROXY', + workspaceId, + ); return { id: postgresCredentials.id, diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts index 82a3b03774c4..b13dea0b937d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.service.ts @@ -72,6 +72,7 @@ export class RemoteServerService { ...remoteServerInput.userMappingOptions, password: this.encryptPassword( remoteServerInput.userMappingOptions.password, + workspaceId, ), }, }; @@ -156,6 +157,7 @@ export class RemoteServerService { ...partialRemoteServerWithUpdates.userMappingOptions, password: this.encryptPassword( partialRemoteServerWithUpdates.userMappingOptions.password, + workspaceId, ), }, }; @@ -252,8 +254,11 @@ export class RemoteServerService { }); } - private encryptPassword(password: string) { - const key = this.jwtWrapperService.generateAppSecret('REMOTE_SERVER'); + private encryptPassword(password: string, workspaceId: string) { + const key = this.jwtWrapperService.generateAppSecret( + 'REMOTE_SERVER', + workspaceId, + ); return encryptText(password, key); } From c0797ffc5cfaf848d9d41fd4ada4f3a967d236c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 21 Oct 2024 21:12:39 +0200 Subject: [PATCH 06/17] Sign with workspaceId --- .../auth/services/auth.service.ts | 20 ++++- .../services/password-reset.service.spec.ts | 0 .../auth/services/password-reset.service.ts | 0 .../auth/strategies/jwt.auth.strategy.ts | 17 +++- .../services/token-generator.service.spec.ts | 0 .../token/services/token-generator.service.ts | 0 .../token-invalidator.service.spec.ts | 0 .../services/token-invalidator.service.ts | 0 .../services/token-refresher.service.spec.ts | 0 .../token/services/token-refresher.service.ts | 0 .../services/token-validator.service.spec.ts | 0 .../token/services/token-validator.service.ts | 0 .../auth/token/services/token.service.ts | 82 ++++++++----------- .../file/guards/file-path-guard.ts | 37 +++++---- .../file/services/file.service.ts | 5 +- .../jwt/services/jwt-wrapper.service.ts | 65 ++++++++++++--- 16 files changed, 144 insertions(+), 82 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts 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 bba83839a315..8e741aabb2a2 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 @@ -152,8 +152,14 @@ export class AuthService { // passwordHash is hidden for security reasons user.passwordHash = ''; - const accessToken = await this.tokenService.generateAccessToken(user.id); - const refreshToken = await this.tokenService.generateRefreshToken(user.id); + const accessToken = await this.tokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.tokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); return { user, @@ -211,8 +217,14 @@ export class AuthService { ); } - const accessToken = await this.tokenService.generateAccessToken(user.id); - const refreshToken = await this.tokenService.generateRefreshToken(user.id); + const accessToken = await this.tokenService.generateAccessToken( + user.id, + user.defaultWorkspaceId, + ); + const refreshToken = await this.tokenService.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); return { user, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 66d93b606bcc..f301acd3c64c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -40,7 +40,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: jwtWrapperService.generateAppSecret('ACCESS'), + secretOrKeyProvider: async (request, rawJwtToken, done) => { + try { + const decodedToken = this.jwtWrapperService.decode( + rawJwtToken, + ) as JwtPayload; + const workspaceId = decodedToken.workspaceId; + const secret = this.jwtWrapperService.generateAppSecret( + 'ACCESS', + workspaceId, + ); + + done(null, secret); + } catch (error) { + done(error, null); + } + }, }); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index d6c200e6c547..aa77621fd8ac 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -6,7 +6,6 @@ import crypto from 'crypto'; import { render } from '@react-email/render'; import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; import { Request } from 'express'; -import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import ms from 'ms'; import { ExtractJwt } from 'passport-jwt'; import { PasswordResetLinkEmail } from 'twenty-emails'; @@ -66,7 +65,7 @@ export class TokenService { async generateAccessToken( userId: string, - workspaceId?: string, + workspaceId: string, ): Promise { const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); @@ -134,13 +133,21 @@ export class TokenService { }; return { - token: this.jwtWrapperService.sign(jwtPayload), + token: this.jwtWrapperService.sign(jwtPayload, { + secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), + }), expiresAt, }; } - async generateRefreshToken(userId: string): Promise { - const secret = this.jwtWrapperService.generateAppSecret('REFRESH'); + async generateRefreshToken( + userId: string, + workspaceId: string, + ): Promise { + const secret = this.jwtWrapperService.generateAppSecret( + 'REFRESH', + workspaceId, + ); const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); if (!expiresIn) { @@ -306,10 +313,10 @@ export class TokenService { AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } - const decoded = await this.verifyJwt( - token, - this.jwtWrapperService.generateAppSecret('ACCESS'), - ); + + await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + + const decoded = await this.jwtWrapperService.decode(token); const { user, apiKey, workspace, workspaceMemberId } = await this.jwtStrategy.validate(decoded as JwtPayload); @@ -318,11 +325,11 @@ export class TokenService { } async verifyLoginToken(loginToken: string): Promise { - const loginTokenSecret = this.jwtWrapperService.generateAppSecret('LOGIN'); + await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); - const payload = await this.verifyJwt(loginToken, loginTokenSecret); - - return payload.sub; + return this.jwtWrapperService.decode(loginToken, { + json: true, + }).sub; } async verifyTransientToken(transientToken: string): Promise<{ @@ -330,10 +337,9 @@ export class TokenService { userId: string; workspaceId: string; }> { - const transientTokenSecret = - this.jwtWrapperService.generateAppSecret('LOGIN'); + await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN'); - const payload = await this.verifyJwt(transientToken, transientTokenSecret); + const payload = await this.jwtWrapperService.decode(transientToken); return { workspaceMemberId: payload.sub, @@ -384,7 +390,7 @@ export class TokenService { }); const token = await this.generateAccessToken(user.id, workspaceId); - const refreshToken = await this.generateRefreshToken(user.id); + const refreshToken = await this.generateRefreshToken(user.id, workspaceId); return { tokens: { @@ -503,7 +509,10 @@ export class TokenService { user.id, user.defaultWorkspaceId, ); - const refreshToken = await this.generateRefreshToken(user.id); + const refreshToken = await this.generateRefreshToken( + user.id, + user.defaultWorkspaceId, + ); const loginToken = await this.generateLoginToken(user.email); return { @@ -514,9 +523,10 @@ export class TokenService { } async verifyRefreshToken(refreshToken: string) { - const secret = this.jwtWrapperService.generateAppSecret('REFRESH'); const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); - const jwtPayload = await this.verifyJwt(refreshToken, secret); + + await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH'); + const jwtPayload = await this.jwtWrapperService.decode(refreshToken); if (!(jwtPayload.jti && jwtPayload.sub)) { throw new AuthException( @@ -589,7 +599,7 @@ export class TokenService { const { user, - token: { id }, + token: { id, workspaceId }, } = await this.verifyRefreshToken(token); // Revoke old refresh token @@ -602,8 +612,8 @@ export class TokenService { }, ); - const accessToken = await this.generateAccessToken(user.id); - const refreshToken = await this.generateRefreshToken(user.id); + const accessToken = await this.generateAccessToken(user.id, workspaceId); + const refreshToken = await this.generateRefreshToken(user.id, workspaceId); return { accessToken, @@ -617,32 +627,6 @@ export class TokenService { )}/verify?loginToken=${loginToken}`; } - async verifyJwt(token: string, secret?: string) { - try { - return this.jwtWrapperService.verify( - token, - secret ? { secret } : undefined, - ); - } catch (error) { - if (error instanceof TokenExpiredError) { - throw new AuthException( - 'Token has expired.', - AuthExceptionCode.UNAUTHENTICATED, - ); - } else if (error instanceof JsonWebTokenError) { - throw new AuthException( - 'Token invalid.', - AuthExceptionCode.UNAUTHENTICATED, - ); - } else { - throw new AuthException( - 'Unknown token error.', - AuthExceptionCode.INVALID_INPUT, - ); - } - } - } - async generatePasswordResetToken(email: string): Promise { const user = await this.userRepository.findOneBy({ email, diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 932bbdffd72d..1cd6ff889c1d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -16,27 +16,34 @@ export class FilePathGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const query = request.query; - if (query && query['token']) { - const payloadToDecode = query['token']; - const decodedPayload = await this.jwtWrapperService.decode( - payloadToDecode, - { - secret: this.jwtWrapperService.generateAppSecret('FILE'), - } as any, - ); + if (!query || !query['token']) { + return false; + } + + const payload = await this.jwtWrapperService.verifyWorkspaceToken( + query['token'], + 'FILE', + ); - const expirationDate = decodedPayload?.['expiration_date']; - const workspaceId = decodedPayload?.['workspace_id']; + if (!payload.workspaceId) { + return false; + } + + const decodedPayload = await this.jwtWrapperService.decode(query['token'], { + json: true, + }); - const isExpired = await this.isExpired(expirationDate); + const expirationDate = decodedPayload?.['expiration_date']; + const workspaceId = decodedPayload?.['workspace_id']; - if (isExpired) { - return false; - } + const isExpired = await this.isExpired(expirationDate); - request.workspaceId = workspaceId; + if (isExpired) { + return false; } + request.workspaceId = workspaceId; + return true; } diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index acfc6cbf321b..45a7a8ad44cf 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -34,7 +34,10 @@ export class FileService { const fileTokenExpiresIn = this.environmentService.get( 'FILE_TOKEN_EXPIRES_IN', ); - const secret = this.jwtWrapperService.generateAppSecret('FILE'); + const secret = this.jwtWrapperService.generateAppSecret( + 'FILE', + payloadToEncode.workspace_id, + ); const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn)); diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 45bcb56c703a..e78986b2cadb 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -1,12 +1,24 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; import { createHash } from 'crypto'; import * as jwt from 'jsonwebtoken'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +type WorkspaceTokenType = + | 'ACCESS' + | 'LOGIN' + | 'REFRESH' + | 'FILE' + | 'POSTGRES_PROXY' + | 'REMOTE_SERVER'; + @Injectable() export class JwtWrapperService { constructor( @@ -27,20 +39,49 @@ export class JwtWrapperService { return this.jwtService.verify(token, options); } - decode(payload: string, options: jwt.DecodeOptions): T { + decode(payload: string, options?: jwt.DecodeOptions): T { return this.jwtService.decode(payload, options); } - generateAppSecret( - type: - | 'ACCESS' - | 'LOGIN' - | 'REFRESH' - | 'FILE' - | 'POSTGRES_PROXY' - | 'REMOTE_SERVER', - workspaceId?: string, - ): string { + verifyWorkspaceToken( + token: string, + type: WorkspaceTokenType, + options?: JwtVerifyOptions, + ) { + const payload = this.decode(token, { + json: true, + }); + + if (!payload.sub) { + throw new UnauthorizedException('No payload sub'); + } + + try { + return this.jwtService.verify(token, { + ...options, + secret: this.generateAppSecret(type, payload.workspaceId), + }); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new AuthException( + 'Token has expired.', + AuthExceptionCode.UNAUTHENTICATED, + ); + } else if (error instanceof jwt.JsonWebTokenError) { + throw new AuthException( + 'Token invalid.', + AuthExceptionCode.UNAUTHENTICATED, + ); + } else { + throw new AuthException( + 'Unknown token error.', + AuthExceptionCode.INVALID_INPUT, + ); + } + } + } + + generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string { const appSecret = this.environmentService.get('APP_SECRET'); if (!appSecret) { From 5d21376c42448f786f81cb95f3874df86029a042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 21 Oct 2024 22:19:11 +0200 Subject: [PATCH 07/17] Begin refactoring --- .../core-query-builder.factory.ts | 6 +- .../metadata/rest-api-metadata.service.ts | 12 +- .../engine/core-modules/auth/auth.module.ts | 22 +- .../engine/core-modules/auth/auth.resolver.ts | 34 +- .../controllers/google-auth.controller.ts | 6 +- .../controllers/microsoft-auth.controller.ts | 6 +- .../auth/controllers/sso-auth.controller.ts | 4 +- .../controllers/verify-auth.controller.ts | 6 +- .../auth/services/auth.service.ts | 6 +- .../auth/services/password-reset.service.ts | 0 .../services/reset-password.service.spec.ts | 213 +++++++++++++ .../auth/services/reset-password.service.ts | 237 ++++++++++++++ .../services/access-token.service.spec.ts | 192 ++++++++++++ .../token/services/access-token.service.ts | 134 ++++++++ .../services/login-token.service.spec.ts} | 0 .../token/services/login-token.service.ts | 53 ++++ .../services/token-generator.service.spec.ts | 0 .../token/services/token-generator.service.ts | 0 .../token-invalidator.service.spec.ts | 0 .../services/token-invalidator.service.ts | 0 .../services/token-refresher.service.spec.ts | 0 .../token/services/token-refresher.service.ts | 0 .../services/token-validator.service.spec.ts | 0 .../token/services/token-validator.service.ts | 0 .../auth/token/services/token.service.ts | 292 +----------------- .../core-modules/auth/token/token.module.ts | 13 +- .../core-modules/open-api/open-api.service.ts | 7 +- .../src/engine/guards/jwt-auth.guard.ts | 6 +- ...l-hydrate-request-from-token.middleware.ts | 12 +- 29 files changed, 935 insertions(+), 326 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts rename packages/twenty-server/src/engine/core-modules/auth/{services/password-reset.service.spec.ts => token/services/login-token.service.spec.ts} (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts index 43c9b0198b5f..5f0d63966b1c 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/core-query-builder.factory.ts @@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { Query } from 'src/engine/api/rest/core/types/query.type'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -39,7 +39,7 @@ export class CoreQueryBuilderFactory { private readonly getVariablesFactory: GetVariablesFactory, private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory, private readonly objectMetadataService: ObjectMetadataService, - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly environmentService: EnvironmentService, ) {} @@ -50,7 +50,7 @@ export class CoreQueryBuilderFactory { objectMetadataItems: ObjectMetadataEntity[]; objectMetadataItem: ObjectMetadataEntity; }> { - const { workspace } = await this.tokenService.validateToken(request); + const { workspace } = await this.accessTokenService.validateToken(request); const objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts index 29f117894869..c53f82783889 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.service.ts @@ -7,18 +7,18 @@ import { GraphqlApiType, RestApiService, } from 'src/engine/api/rest/rest-api.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; @Injectable() export class RestApiMetadataService { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory, private readonly restApiService: RestApiService, ) {} async get(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.get(request); return await this.restApiService.call( @@ -29,7 +29,7 @@ export class RestApiMetadataService { } async create(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.create(request); return await this.restApiService.call( @@ -40,7 +40,7 @@ export class RestApiMetadataService { } async update(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.update(request); return await this.restApiService.call( @@ -51,7 +51,7 @@ export class RestApiMetadataService { } async delete(request: Request) { - await this.tokenService.validateToken(request); + await this.accessTokenService.validateToken(request); const data = await this.metadataQueryBuilderFactory.delete(request); return await this.restApiService.call( diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 6a102e8b360c..73c430857172 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -9,31 +9,34 @@ import { AppTokenService } from 'src/engine/core-modules/app-token/services/app- import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller'; import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller'; import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; +import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; +import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; +import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; -import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; -import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; -import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; -import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; -import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; -import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module'; import { AuthResolver } from './auth.resolver'; @@ -86,7 +89,10 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; TokenService, GoogleAPIsService, AppTokenService, + AccessTokenService, + LoginTokenService, + ResetPasswordService, ], - exports: [TokenService], + exports: [TokenService, AccessTokenService, LoginTokenService], }) export class AuthModule {} 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 2e470589ef07..847b843248ce 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 @@ -10,12 +10,19 @@ import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/em import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input'; +import { + GenerateJWTOutput, + GenerateJWTOutputWithAuthTokens, + GenerateJWTOutputWithSSOAUTH, +} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -24,11 +31,6 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { - GenerateJWTOutput, - GenerateJWTOutputWithAuthTokens, - GenerateJWTOutputWithSSOAUTH, -} from 'src/engine/core-modules/auth/dto/generateJWT.output'; import { ChallengeInput } from './dto/challenge.input'; import { ImpersonateInput } from './dto/impersonate.input'; @@ -51,6 +53,8 @@ export class AuthResolver { private authService: AuthService, private tokenService: TokenService, private userService: UserService, + private resetPasswordService: ResetPasswordService, + private loginTokenService: LoginTokenService, ) {} @UseGuards(CaptchaGuard) @@ -87,7 +91,9 @@ export class AuthResolver { @Mutation(() => LoginToken) async challenge(@Args() challengeInput: ChallengeInput): Promise { const user = await this.authService.challenge(challengeInput); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return { loginToken }; } @@ -100,7 +106,9 @@ export class AuthResolver { fromSSO: false, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return { loginToken }; } @@ -141,7 +149,7 @@ export class AuthResolver { @Mutation(() => Verify) async verify(@Args() verifyInput: VerifyInput): Promise { - const email = await this.tokenService.verifyLoginToken( + const email = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); @@ -240,7 +248,7 @@ export class AuthResolver { emailPasswordResetInput.email, ); - return await this.tokenService.sendEmailPasswordResetLink( + return await this.resetPasswordService.sendEmailPasswordResetLink( resetToken, emailPasswordResetInput.email, ); @@ -252,18 +260,20 @@ export class AuthResolver { { passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput, ): Promise { const { id } = - await this.tokenService.validatePasswordResetToken(passwordResetToken); + await this.resetPasswordService.validatePasswordResetToken( + passwordResetToken, + ); await this.authService.updatePassword(id, newPassword); - return await this.tokenService.invalidatePasswordResetToken(id); + return await this.resetPasswordService.invalidatePasswordResetToken(id); } @Query(() => ValidatePasswordResetToken) async validatePasswordResetToken( @Args() args: ValidatePasswordResetTokenInput, ): Promise { - return this.tokenService.validatePasswordResetToken( + return this.resetPasswordService.validatePasswordResetToken( args.passwordResetToken, ); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index c674569d43bf..b67676c0b466 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -15,6 +15,7 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/google') @@ -22,6 +23,7 @@ import { TokenService } from 'src/engine/core-modules/auth/token/services/token. export class GoogleAuthController { constructor( private readonly tokenService: TokenService, + private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, ) {} @@ -55,7 +57,9 @@ export class GoogleAuthController { fromSSO: true, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index 49fa5384b3b4..b3488d18a1c7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -15,12 +15,14 @@ import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microso import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) export class MicrosoftAuthController { constructor( + private readonly loginTokenService: LoginTokenService, private readonly tokenService: TokenService, private readonly typeORMService: TypeORMService, private readonly authService: AuthService, @@ -58,7 +60,9 @@ export class MicrosoftAuthController { fromSSO: true, }); - const loginToken = await this.tokenService.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 18b9dbb4d6bf..66a591745d81 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -24,6 +24,7 @@ import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.gua import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard'; import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; @@ -38,6 +39,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in @UseFilters(AuthRestApiExceptionFilter) export class SSOAuthController { constructor( + private readonly loginTokenService: LoginTokenService, private readonly tokenService: TokenService, private readonly authService: AuthService, private readonly workspaceInvitationService: WorkspaceInvitationService, @@ -156,6 +158,6 @@ export class SSOAuthController { ); } - return this.tokenService.generateLoginToken(user.email); + return this.loginTokenService.generateLoginToken(user.email); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts index 25a52dc3b202..9fcfbb0cf91d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.ts @@ -4,19 +4,19 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @Controller('auth/verify') @UseFilters(AuthRestApiExceptionFilter) export class VerifyAuthController { constructor( private readonly authService: AuthService, - private readonly tokenService: TokenService, + private readonly loginTokenService: LoginTokenService, ) {} @Post() async verify(@Body() verifyInput: VerifyInput): Promise { - const email = await this.tokenService.verifyLoginToken( + const email = await this.loginTokenService.verifyLoginToken( verifyInput.loginToken, ); const result = await this.authService.verify(email); 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 ab346e278d5b..fe23bfacdb48 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 @@ -32,6 +32,7 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity' import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -41,6 +42,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class AuthService { constructor( + private readonly accessTokenService: AccessTokenService, private readonly tokenService: TokenService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') @@ -150,7 +152,7 @@ export class AuthService { // passwordHash is hidden for security reasons user.passwordHash = ''; - const accessToken = await this.tokenService.generateAccessToken( + const accessToken = await this.accessTokenService.generateAccessToken( user.id, user.defaultWorkspaceId, ); @@ -215,7 +217,7 @@ export class AuthService { ); } - const accessToken = await this.tokenService.generateAccessToken( + const accessToken = await this.accessTokenService.generateAccessToken( user.id, user.defaultWorkspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts new file mode 100644 index 000000000000..f6500022e64b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -0,0 +1,213 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import { Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +import { ResetPasswordService } from './reset-password.service'; + +describe('ResetPasswordService', () => { + let service: ResetPasswordService; + let userRepository: Repository; + let appTokenRepository: Repository; + let emailService: EmailService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResetPasswordService, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: EmailService, + useValue: { + send: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ResetPasswordService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + emailService = module.get(EmailService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generatePasswordResetToken', () => { + it('should generate a password reset token for a valid user', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken); + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + + const result = + await service.generatePasswordResetToken('test@example.com'); + + expect(result.passwordResetToken).toBeDefined(); + expect(result.passwordResetTokenExpiresAt).toBeDefined(); + expect(appTokenRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: '1', + type: AppTokenType.PasswordResetToken, + }), + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.generatePasswordResetToken('nonexistent@example.com'), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if a token already exists', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockExistingToken = { + userId: '1', + type: AppTokenType.PasswordResetToken, + expiresAt: addMilliseconds(new Date(), 3600000), + }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest + .spyOn(appTokenRepository, 'findOne') + .mockResolvedValue(mockExistingToken as AppToken); + + await expect( + service.generatePasswordResetToken('test@example.com'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('sendEmailPasswordResetLink', () => { + it('should send a password reset email', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + const mockToken = { + passwordResetToken: 'token123', + passwordResetTokenExpiresAt: new Date(), + }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest + .spyOn(environmentService, 'get') + .mockReturnValue('http://localhost:3000'); + jest.spyOn(emailService, 'send').mockResolvedValue({} as any); + + const result = await service.sendEmailPasswordResetLink( + mockToken, + 'test@example.com', + ); + + expect(result.success).toBe(true); + expect(emailService.send).toHaveBeenCalled(); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.sendEmailPasswordResetLink( + {} as any, + 'nonexistent@example.com', + ), + ).rejects.toThrow(AuthException); + }); + }); + + describe('validatePasswordResetToken', () => { + it('should validate a correct password reset token', async () => { + const mockToken = { + userId: '1', + type: AppTokenType.PasswordResetToken, + expiresAt: addMilliseconds(new Date(), 3600000), + }; + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(appTokenRepository, 'findOne') + .mockResolvedValue(mockToken as AppToken); + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + + const result = await service.validatePasswordResetToken('validToken'); + + expect(result).toEqual({ id: '1', email: 'test@example.com' }); + }); + + it('should throw an error for an invalid token', async () => { + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.validatePasswordResetToken('invalidToken'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('invalidatePasswordResetToken', () => { + it('should invalidate an existing password reset token', async () => { + const mockUser = { id: '1', email: 'test@example.com' }; + + jest + .spyOn(userRepository, 'findOneBy') + .mockResolvedValue(mockUser as User); + jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); + + const result = await service.invalidatePasswordResetToken('1'); + + expect(result.success).toBe(true); + expect(appTokenRepository.update).toHaveBeenCalledWith( + { userId: '1', type: AppTokenType.PasswordResetToken }, + { revokedAt: expect.any(Date) }, + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.invalidatePasswordResetToken('nonexistent'), + ).rejects.toThrow(AuthException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts new file mode 100644 index 000000000000..9aa74aa218c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { render } from '@react-email/render'; +import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { PasswordResetLinkEmail } from 'twenty-emails'; +import { IsNull, MoreThan, Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; +import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; +import { PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; +import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +@Injectable() +export class ResetPasswordService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly jwtStrategy: JwtAuthStrategy, + private readonly environmentService: EnvironmentService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly emailService: EmailService, + private readonly sSSOService: SSOService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly accessTokenService: AccessTokenService, + ) {} + + async generatePasswordResetToken(email: string): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const expiresIn = this.environmentService.get( + 'PASSWORD_RESET_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const existingToken = await this.appTokenRepository.findOne({ + where: { + userId: user.id, + type: AppTokenType.PasswordResetToken, + expiresAt: MoreThan(new Date()), + revokedAt: IsNull(), + }, + }); + + if (existingToken) { + const timeToWait = ms( + differenceInMilliseconds(existingToken.expiresAt, new Date()), + { long: true }, + ); + + throw new AuthException( + `Token has already been generated. Please wait for ${timeToWait} to generate again.`, + AuthExceptionCode.INVALID_INPUT, + ); + } + + const plainResetToken = crypto.randomBytes(32).toString('hex'); + const hashedResetToken = crypto + .createHash('sha256') + .update(plainResetToken) + .digest('hex'); + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + await this.appTokenRepository.save({ + userId: user.id, + value: hashedResetToken, + expiresAt, + type: AppTokenType.PasswordResetToken, + }); + + return { + passwordResetToken: plainResetToken, + passwordResetTokenExpiresAt: expiresAt, + }; + } + + async sendEmailPasswordResetLink( + resetToken: PasswordResetToken, + email: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + email, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); + const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; + + const emailData = { + link: resetLink, + duration: ms( + differenceInMilliseconds( + resetToken.passwordResetTokenExpiresAt, + new Date(), + ), + { + long: true, + }, + ), + }; + + const emailTemplate = PasswordResetLinkEmail(emailData); + const html = render(emailTemplate, { + pretty: true, + }); + + const text = render(emailTemplate, { + plainText: true, + }); + + this.emailService.send({ + from: `${this.environmentService.get( + 'EMAIL_FROM_NAME', + )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, + to: email, + subject: 'Action Needed to Reset Password', + text, + html, + }); + + return { success: true }; + } + + async validatePasswordResetToken( + resetToken: string, + ): Promise { + const hashedResetToken = crypto + .createHash('sha256') + .update(resetToken) + .digest('hex'); + + const token = await this.appTokenRepository.findOne({ + where: { + value: hashedResetToken, + type: AppTokenType.PasswordResetToken, + expiresAt: MoreThan(new Date()), + revokedAt: IsNull(), + }, + }); + + if (!token || !token.userId) { + throw new AuthException( + 'Token is invalid', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + const user = await this.userRepository.findOneBy({ + id: token.userId, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + return { + id: user.id, + email: user.email, + }; + } + + async invalidatePasswordResetToken( + userId: string, + ): Promise { + const user = await this.userRepository.findOneBy({ + id: userId, + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + await this.appTokenRepository.update( + { + userId, + type: AppTokenType.PasswordResetToken, + }, + { + revokedAt: new Date(), + }, + ); + + return { success: true }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts new file mode 100644 index 000000000000..92e987d25fa4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Request } from 'express'; +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; + +import { AccessTokenService } from './access-token.service'; + +describe('AccessTokenService', () => { + let service: AccessTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + let userRepository: Repository; + let twentyORMGlobalManager: TwentyORMGlobalManager; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AccessTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn(), + }, + }, + { + provide: JwtAuthStrategy, + useValue: { + validate: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: EmailService, + useValue: {}, + }, + { + provide: SSOService, + useValue: {}, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AccessTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + twentyORMGlobalManager = module.get( + TwentyORMGlobalManager, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateAccessToken', () => { + it('should generate an access token successfully', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockUser = { + id: userId, + defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' }, + defaultWorkspaceId: workspaceId, + }; + const mockWorkspaceMember = { id: 'workspace-member-id' }; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue({ + findOne: jest.fn().mockResolvedValue(mockWorkspaceMember), + } as any); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateAccessToken(userId, workspaceId); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: userId, + workspaceId: workspaceId, + workspaceMemberId: mockWorkspaceMember.id, + }), + expect.any(Object), + ); + }); + + it('should throw an error if user is not found', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.generateAccessToken('non-existent-user', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('validateToken', () => { + it('should validate a token successfully', async () => { + const mockToken = 'valid-token'; + const mockRequest = { + headers: { + authorization: `Bearer ${mockToken}`, + }, + } as Request; + const mockDecodedToken = { sub: 'user-id', workspaceId: 'workspace-id' }; + const mockAuthContext = { + user: { id: 'user-id' }, + apiKey: null, + workspace: { id: 'workspace-id' }, + workspaceMemberId: 'workspace-member-id', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest + .spyOn(jwtWrapperService, 'decode') + .mockReturnValue(mockDecodedToken as any); + jest + .spyOn(service['jwtStrategy'], 'validate') + .mockReturnValue(mockAuthContext as any); + + const result = await service.validateToken(mockRequest); + + expect(result).toEqual(mockAuthContext); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'ACCESS', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); + expect(service['jwtStrategy'].validate).toHaveBeenCalledWith( + mockDecodedToken, + ); + }); + + it('should throw an error if token is missing', async () => { + const mockRequest = { + headers: {}, + } as Request; + + await expect(service.validateToken(mockRequest)).rejects.toThrow( + AuthException, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts new file mode 100644 index 000000000000..20baf3742c45 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import { Request } from 'express'; +import ms from 'ms'; +import { ExtractJwt } from 'passport-jwt'; +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { + JwtAuthStrategy, + JwtPayload, +} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +export class AccessTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly jwtStrategy: JwtAuthStrategy, + private readonly environmentService: EnvironmentService, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async generateAccessToken( + userId: string, + workspaceId: string, + ): Promise { + const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['defaultWorkspace'], + }); + + if (!user) { + throw new AuthException( + 'User is not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if (!user.defaultWorkspace) { + throw new AuthException( + 'User does not have a default workspace', + AuthExceptionCode.INVALID_DATA, + ); + } + + const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId; + let tokenWorkspaceMemberId: string | undefined; + + if ( + user.defaultWorkspace.activationStatus === + WorkspaceActivationStatus.ACTIVE + ) { + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + tokenWorkspaceId, + 'workspaceMember', + ); + + const workspaceMember = await workspaceMemberRepository.findOne({ + where: { + userId: user.id, + }, + }); + + if (!workspaceMember) { + throw new AuthException( + 'User is not a member of the workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + tokenWorkspaceMemberId = workspaceMember.id; + } + + const jwtPayload: JwtPayload = { + sub: user.id, + workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId, + workspaceMemberId: tokenWorkspaceMemberId, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), + }), + expiresAt, + }; + } + + async validateToken(request: Request): Promise { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + if (!token) { + throw new AuthException( + 'missing authentication token', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); + + const decoded = await this.jwtWrapperService.decode(token); + + const { user, apiKey, workspace, workspaceMemberId } = + await this.jwtStrategy.validate(decoded as JwtPayload); + + return { user, apiKey, workspace, workspaceMemberId }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/auth/services/password-reset.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts new file mode 100644 index 000000000000..24c96b4e42c5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class LoginTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateLoginToken(email: string): Promise { + const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); + const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + const jwtPayload = { + sub: email, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + }), + expiresAt, + }; + } + + async verifyLoginToken(loginToken: string): Promise { + await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); + + return this.jwtWrapperService.decode(loginToken, { + json: true, + }).sub; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.spec.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-generator.service.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.spec.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-invalidator.service.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.spec.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-refresher.service.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.spec.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token-validator.service.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index 4123a7e06cdb..60abdc4fcd49 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -3,12 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import crypto from 'crypto'; -import { render } from '@react-email/render'; import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; import { Request } from 'express'; import ms from 'ms'; import { ExtractJwt } from 'passport-jwt'; -import { PasswordResetLinkEmail } from 'twenty-emails'; import { IsNull, MoreThan, Repository } from 'typeorm'; import { @@ -19,40 +17,27 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; -import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { ApiKeyToken, AuthToken, AuthTokens, PasswordResetToken, } from 'src/engine/core-modules/auth/dto/token.entity'; -import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; -import { - JwtAuthStrategy, - JwtPayload, -} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { - Workspace, - WorkspaceActivationStatus, -} from 'src/engine/core-modules/workspace/workspace.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { generateSecret } from 'src/utils/generate-secret'; @Injectable() export class TokenService { constructor( private readonly jwtWrapperService: JwtWrapperService, - private readonly jwtStrategy: JwtAuthStrategy, private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @@ -60,88 +45,11 @@ export class TokenService { private readonly appTokenRepository: Repository, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, - private readonly emailService: EmailService, private readonly sSSOService: SSOService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly accessTokenService: AccessTokenService, + private readonly loginTokenService: LoginTokenService, ) {} - async generateAccessToken( - userId: string, - workspaceId: string, - ): Promise { - const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const user = await this.userRepository.findOne({ - where: { id: userId }, - relations: ['defaultWorkspace'], - }); - - if (!user) { - throw new AuthException( - 'User is not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if (!user.defaultWorkspace) { - throw new AuthException( - 'User does not have a default workspace', - AuthExceptionCode.INVALID_DATA, - ); - } - - const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId; - let tokenWorkspaceMemberId: string | undefined; - - if ( - user.defaultWorkspace.activationStatus === - WorkspaceActivationStatus.ACTIVE - ) { - const workspaceMemberRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - tokenWorkspaceId, - 'workspaceMember', - ); - - const workspaceMember = await workspaceMemberRepository.findOne({ - where: { - userId: user.id, - }, - }); - - if (!workspaceMember) { - throw new AuthException( - 'User is not a member of the workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - tokenWorkspaceMemberId = workspaceMember.id; - } - - const jwtPayload: JwtPayload = { - sub: user.id, - workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId, - workspaceMemberId: tokenWorkspaceMemberId, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), - }), - expiresAt, - }; - } - async generateRefreshToken( userId: string, workspaceId: string, @@ -212,31 +120,6 @@ export class TokenService { return this.appTokenRepository.save(invitationToken); } - async generateLoginToken(email: string): Promise { - const secret = this.jwtWrapperService.generateAppSecret('LOGIN'); - const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: email, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - }), - expiresAt, - }; - } - async generateTransientToken( workspaceMemberId: string, userId: string, @@ -306,34 +189,6 @@ export class TokenService { return !!token; } - async validateToken(request: Request): Promise { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - if (!token) { - throw new AuthException( - 'missing authentication token', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS'); - - const decoded = await this.jwtWrapperService.decode(token); - - const { user, apiKey, workspace, workspaceMemberId } = - await this.jwtStrategy.validate(decoded as JwtPayload); - - return { user, apiKey, workspace, workspaceMemberId }; - } - - async verifyLoginToken(loginToken: string): Promise { - await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN'); - - return this.jwtWrapperService.decode(loginToken, { - json: true, - }).sub; - } - async verifyTransientToken(transientToken: string): Promise<{ workspaceMemberId: string; userId: string; @@ -420,7 +275,10 @@ export class TokenService { defaultWorkspace: workspace, }); - const token = await this.generateAccessToken(user.id, workspace.id); + const token = await this.accessTokenService.generateAccessToken( + user.id, + workspace.id, + ); const refreshToken = await this.generateRefreshToken(user.id, workspace.id); return { @@ -536,7 +394,7 @@ export class TokenService { ); } - const accessToken = await this.generateAccessToken( + const accessToken = await this.accessTokenService.generateAccessToken( user.id, user.defaultWorkspaceId, ); @@ -544,7 +402,9 @@ export class TokenService { user.id, user.defaultWorkspaceId, ); - const loginToken = await this.generateLoginToken(user.email); + const loginToken = await this.loginTokenService.generateLoginToken( + user.email, + ); return { accessToken, @@ -643,7 +503,10 @@ export class TokenService { }, ); - const accessToken = await this.generateAccessToken(user.id, workspaceId); + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + workspaceId, + ); const refreshToken = await this.generateRefreshToken(user.id, workspaceId); return { @@ -722,125 +585,4 @@ export class TokenService { passwordResetTokenExpiresAt: expiresAt, }; } - - async sendEmailPasswordResetLink( - resetToken: PasswordResetToken, - email: string, - ): Promise { - const user = await this.userRepository.findOneBy({ - email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); - const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; - - const emailData = { - link: resetLink, - duration: ms( - differenceInMilliseconds( - resetToken.passwordResetTokenExpiresAt, - new Date(), - ), - { - long: true, - }, - ), - }; - - const emailTemplate = PasswordResetLinkEmail(emailData); - const html = render(emailTemplate, { - pretty: true, - }); - - const text = render(emailTemplate, { - plainText: true, - }); - - this.emailService.send({ - from: `${this.environmentService.get( - 'EMAIL_FROM_NAME', - )} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`, - to: email, - subject: 'Action Needed to Reset Password', - text, - html, - }); - - return { success: true }; - } - - async validatePasswordResetToken( - resetToken: string, - ): Promise { - const hashedResetToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - - const token = await this.appTokenRepository.findOne({ - where: { - value: hashedResetToken, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - - if (!token || !token.userId) { - throw new AuthException( - 'Token is invalid', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - const user = await this.userRepository.findOneBy({ - id: token.userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - return { - id: user.id, - email: user.email, - }; - } - - async invalidatePasswordResetToken( - userId: string, - ): Promise { - const user = await this.userRepository.findOneBy({ - id: userId, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - await this.appTokenRepository.update( - { - userId, - type: AppTokenType.PasswordResetToken, - }, - { - revokedAt: new Date(), - }, - ); - - return { success: true }; - } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 42d65621e528..084011711679 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -5,13 +5,15 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; @Module({ imports: [ @@ -22,7 +24,12 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; EmailModule, WorkspaceSSOModule, ], - providers: [TokenService, JwtAuthStrategy], - exports: [TokenService], + providers: [ + TokenService, + JwtAuthStrategy, + AccessTokenService, + LoginTokenService, + ], + exports: [TokenService, AccessTokenService, LoginTokenService], }) export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 4628fef4f2fb..d7168bca81b6 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { Request } from 'express'; import { OpenAPIV3_1 } from 'openapi-types'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils'; import { @@ -41,7 +41,7 @@ import { getServerUrl } from 'src/utils/get-server-url'; @Injectable() export class OpenApiService { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly environmentService: EnvironmentService, private readonly objectMetadataService: ObjectMetadataService, ) {} @@ -57,7 +57,8 @@ export class OpenApiService { let objectMetadataItems; try { - const { workspace } = await this.tokenService.validateToken(request); + const { workspace } = + await this.accessTokenService.validateToken(request); objectMetadataItems = await this.objectMetadataService.findManyWithinWorkspace(workspace.id); diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index d1f920848775..cd173f03c55a 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -1,12 +1,12 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Injectable() export class JwtAuthGuard implements CanActivate { constructor( - private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, ) {} @@ -14,7 +14,7 @@ export class JwtAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); try { - const data = await this.tokenService.validateToken(request); + const data = await this.accessTokenService.validateToken(request); const metadataVersion = await this.workspaceStorageCacheService.getMetadataVersion( data.workspace.id, diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 4ea5ba05ffcf..606549cf4f25 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -3,21 +3,22 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; class GraphqlTokenValidationProxy { - private tokenService: TokenService; + private accessTokenService: AccessTokenService; - constructor(tokenService: TokenService) { - this.tokenService = tokenService; + constructor(accessTokenService: AccessTokenService) { + this.accessTokenService = accessTokenService; } async validateToken(req: Request) { try { - return await this.tokenService.validateToken(req); + return await this.accessTokenService.validateToken(req); } catch (error) { const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter(); @@ -32,6 +33,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware { constructor( private readonly tokenService: TokenService, + private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, private readonly exceptionHandlerService: ExceptionHandlerService, ) {} @@ -69,7 +71,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware try { const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy( - this.tokenService, + this.accessTokenService, ); data = await graphqlTokenValidationProxy.validateToken(req); From 44092197926d1562e706736d85e1491bd1af09ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 08:15:34 +0200 Subject: [PATCH 08/17] Keep splitting TokenService into sub-services --- .../engine/core-modules/auth/auth.module.ts | 2 + .../engine/core-modules/auth/auth.resolver.ts | 20 +- .../auth/services/auth.service.ts | 8 +- .../auth/services/reset-password.service.ts | 11 - .../services/switch-workspace.service.spec.ts | 217 ++++++++++++++ .../auth/services/switch-workspace.service.ts | 115 ++++++++ .../services/refresh-token.service.spec.ts | 156 ++++++++++ .../token/services/refresh-token.service.ts | 138 +++++++++ .../auth/token/services/token.service.spec.ts | 248 ---------------- .../auth/token/services/token.service.ts | 278 +----------------- .../core-modules/auth/token/token.module.ts | 9 +- 11 files changed, 662 insertions(+), 540 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 73c430857172..d003b21ed0be 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -14,6 +14,7 @@ import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/v import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; @@ -92,6 +93,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; AccessTokenService, LoginTokenService, ResetPasswordService, + SwitchWorkspaceService, ], exports: [TokenService, AccessTokenService, LoginTokenService], }) 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 847b843248ce..ed55c42efdc1 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 @@ -22,6 +22,7 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; +import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -55,6 +56,7 @@ export class AuthResolver { private userService: UserService, private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, + private switchWorkspaceService: SwitchWorkspaceService, ) {} @UseGuards(CaptchaGuard) @@ -178,7 +180,7 @@ export class AuthResolver { @AuthUser() user: User, @Args() args: GenerateJwtInput, ): Promise { - const result = await this.tokenService.switchWorkspace( + const result = await this.switchWorkspaceService.switchWorkspace( user, args.workspaceId, ); @@ -202,10 +204,11 @@ export class AuthResolver { return { success: true, reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH', - authTokens: await this.tokenService.generateSwitchWorkspaceToken( - user, - result.workspace, - ), + authTokens: + await this.switchWorkspaceService.generateSwitchWorkspaceToken( + user, + result.workspace, + ), }; } @@ -244,9 +247,10 @@ export class AuthResolver { async emailPasswordResetLink( @Args() emailPasswordResetInput: EmailPasswordResetLinkInput, ): Promise { - const resetToken = await this.tokenService.generatePasswordResetToken( - emailPasswordResetInput.email, - ); + const resetToken = + await this.resetPasswordService.generatePasswordResetToken( + emailPasswordResetInput.email, + ); return await this.resetPasswordService.sendEmailPasswordResetLink( resetToken, 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 fe23bfacdb48..82443c135dde 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 @@ -33,7 +33,7 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -43,7 +43,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, - private readonly tokenService: TokenService, + private readonly refreshTokenService: RefreshTokenService, private readonly signInUpService: SignInUpService, @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, @@ -156,7 +156,7 @@ export class AuthService { user.id, user.defaultWorkspaceId, ); - const refreshToken = await this.tokenService.generateRefreshToken( + const refreshToken = await this.refreshTokenService.generateRefreshToken( user.id, user.defaultWorkspaceId, ); @@ -221,7 +221,7 @@ export class AuthService { user.id, user.defaultWorkspaceId, ); - const refreshToken = await this.tokenService.generateRefreshToken( + const refreshToken = await this.refreshTokenService.generateRefreshToken( user.id, user.defaultWorkspaceId, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts index 9aa74aa218c9..79a73a1c0441 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -21,32 +21,21 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity'; import { PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; -import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @Injectable() export class ResetPasswordService { constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly jwtStrategy: JwtAuthStrategy, private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, private readonly emailService: EmailService, - private readonly sSSOService: SSOService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly accessTokenService: AccessTokenService, ) {} async generatePasswordResetToken(email: string): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts new file mode 100644 index 000000000000..e2981b2f289e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.spec.ts @@ -0,0 +1,217 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { SwitchWorkspaceService } from './switch-workspace.service'; + +describe('SwitchWorkspaceService', () => { + let service: SwitchWorkspaceService; + let userRepository: Repository; + let workspaceRepository: Repository; + let ssoService: SSOService; + let accessTokenService: AccessTokenService; + let refreshTokenService: RefreshTokenService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SwitchWorkspaceService, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, + { + provide: SSOService, + useValue: { + listSSOIdentityProvidersByWorkspaceId: jest.fn(), + }, + }, + { + provide: AccessTokenService, + useValue: { + generateAccessToken: jest.fn(), + }, + }, + { + provide: RefreshTokenService, + useValue: { + generateRefreshToken: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SwitchWorkspaceService); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + workspaceRepository = module.get>( + getRepositoryToken(Workspace, 'core'), + ); + ssoService = module.get(SSOService); + accessTokenService = module.get(AccessTokenService); + refreshTokenService = module.get(RefreshTokenService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('switchWorkspace', () => { + it('should throw an error if user does not exist', async () => { + jest.spyOn(userRepository, 'findBy').mockResolvedValue([]); + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.switchWorkspace( + { id: 'non-existent-user' } as User, + 'workspace-id', + ), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if workspace does not exist', async () => { + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([{ id: 'user-id' } as User]); + jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.switchWorkspace( + { id: 'user-id' } as User, + 'non-existent-workspace', + ), + ).rejects.toThrow(AuthException); + }); + + it('should throw an error if user does not belong to workspace', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'other-user-id' }], + workspaceSSOIdentityProviders: [], + }; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + + await expect( + service.switchWorkspace(mockUser as User, 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + + it('should return SSO auth info if workspace has SSO providers', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'user-id' }], + workspaceSSOIdentityProviders: [{}], + }; + const mockSSOProviders = [{ id: 'sso-provider-id' }]; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + jest + .spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId') + .mockResolvedValue(mockSSOProviders as any); + + const result = await service.switchWorkspace( + mockUser as User, + 'workspace-id', + ); + + expect(result).toEqual({ + useSSOAuth: true, + workspace: mockWorkspace, + availableSSOIdentityProviders: mockSSOProviders, + }); + }); + + it('should return workspace info if workspace does not have SSO providers', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { + id: 'workspace-id', + workspaceUsers: [{ userId: 'user-id' }], + workspaceSSOIdentityProviders: [], + }; + + jest + .spyOn(userRepository, 'findBy') + .mockResolvedValue([mockUser as User]); + jest + .spyOn(workspaceRepository, 'findOne') + .mockResolvedValue(mockWorkspace as any); + + const result = await service.switchWorkspace( + mockUser as User, + 'workspace-id', + ); + + expect(result).toEqual({ + useSSOAuth: false, + workspace: mockWorkspace, + }); + }); + }); + + describe('generateSwitchWorkspaceToken', () => { + it('should generate and return auth tokens', async () => { + const mockUser = { id: 'user-id' }; + const mockWorkspace = { id: 'workspace-id' }; + const mockAccessToken = { token: 'access-token', expiresAt: new Date() }; + const mockRefreshToken = 'refresh-token'; + + jest.spyOn(userRepository, 'save').mockResolvedValue({} as User); + jest + .spyOn(accessTokenService, 'generateAccessToken') + .mockResolvedValue(mockAccessToken); + jest + .spyOn(refreshTokenService, 'generateRefreshToken') + .mockResolvedValue(mockRefreshToken as any); + + const result = await service.generateSwitchWorkspaceToken( + mockUser as User, + mockWorkspace as Workspace, + ); + + expect(result).toEqual({ + tokens: { + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + }, + }); + expect(userRepository.save).toHaveBeenCalledWith({ + id: mockUser.id, + defaultWorkspace: mockWorkspace, + }); + expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspace.id, + ); + expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspace.id, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts new file mode 100644 index 000000000000..88dce7ce76cc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/switch-workspace.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +@Injectable() +export class SwitchWorkspaceService { + constructor( + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, + private readonly ssoService: SSOService, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + async switchWorkspace(user: User, workspaceId: string) { + const userExists = await this.userRepository.findBy({ id: user.id }); + + if (!userExists) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const workspace = await this.workspaceRepository.findOne({ + where: { id: workspaceId }, + relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], + }); + + if (!workspace) { + throw new AuthException( + 'workspace doesnt exist', + AuthExceptionCode.INVALID_INPUT, + ); + } + + if ( + !workspace.workspaceUsers + .map((userWorkspace) => userWorkspace.userId) + .includes(user.id) + ) { + throw new AuthException( + 'user does not belong to workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + if (workspace.workspaceSSOIdentityProviders.length > 0) { + return { + useSSOAuth: true, + workspace, + availableSSOIdentityProviders: + await this.ssoService.listSSOIdentityProvidersByWorkspaceId( + workspaceId, + ), + } as { + useSSOAuth: true; + workspace: Workspace; + availableSSOIdentityProviders: Awaited< + ReturnType< + typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId + > + >; + }; + } + + return { + useSSOAuth: false, + workspace, + } as { + useSSOAuth: false; + workspace: Workspace; + }; + } + + async generateSwitchWorkspaceToken( + user: User, + workspace: Workspace, + ): Promise { + await this.userRepository.save({ + id: user.id, + defaultWorkspace: workspace, + }); + + const token = await this.accessTokenService.generateAccessToken( + user.id, + workspace.id, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + workspace.id, + ); + + return { + tokens: { + accessToken: token, + refreshToken, + }, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts new file mode 100644 index 000000000000..5a254c7621d3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +import { RefreshTokenService } from './refresh-token.service'; + +describe('RefreshTokenService', () => { + let service: RefreshTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + let appTokenRepository: Repository; + let userRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RefreshTokenService, + { + provide: JwtWrapperService, + useValue: { + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + sign: jest.fn(), + generateAppSecret: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: getRepositoryToken(User, 'core'), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(RefreshTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + userRepository = module.get>( + getRepositoryToken(User, 'core'), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('verifyRefreshToken', () => { + it('should verify a refresh token successfully', async () => { + const mockToken = 'valid-refresh-token'; + const mockJwtPayload = { jti: 'token-id', sub: 'user-id' }; + const mockAppToken = { id: 'token-id', revokedAt: null }; + const mockUser: Partial = { + id: 'some-id', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + defaultAvatarUrl: '', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload); + jest + .spyOn(appTokenRepository, 'findOneBy') + .mockResolvedValue(mockAppToken as AppToken); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User); + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + + const result = await service.verifyRefreshToken(mockToken); + + expect(result).toEqual({ user: mockUser, token: mockAppToken }); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'REFRESH', + ); + }); + + it('should throw an error if the token is malformed', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({}); + + await expect(service.verifyRefreshToken(mockToken)).rejects.toThrow( + AuthException, + ); + }); + }); + + describe('generateRefreshToken', () => { + it('should generate a refresh token successfully', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockToken = 'mock-refresh-token'; + const mockExpiresIn = '7d'; + + jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mock-secret'); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(appTokenRepository, 'create') + .mockReturnValue({ id: 'new-token-id' } as AppToken); + jest + .spyOn(appTokenRepository, 'save') + .mockResolvedValue({ id: 'new-token-id' } as AppToken); + + const result = await service.generateRefreshToken(userId, workspaceId); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(appTokenRepository.save).toHaveBeenCalled(); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: userId }, + expect.objectContaining({ + secret: 'mock-secret', + expiresIn: mockExpiresIn, + jwtid: 'new-token-id', + }), + ); + }); + + it('should throw an error if expiration time is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateRefreshToken('user-id', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts new file mode 100644 index 000000000000..7dfe5d68ec5f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { Repository } from 'typeorm'; + +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +@Injectable() +export class RefreshTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + @InjectRepository(User, 'core') + private readonly userRepository: Repository, + ) {} + + async verifyRefreshToken(refreshToken: string) { + const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); + + await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH'); + const jwtPayload = await this.jwtWrapperService.decode(refreshToken); + + if (!(jwtPayload.jti && jwtPayload.sub)) { + throw new AuthException( + 'This refresh token is malformed', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const token = await this.appTokenRepository.findOneBy({ + id: jwtPayload.jti, + }); + + if (!token) { + throw new AuthException( + "This refresh token doesn't exist", + AuthExceptionCode.INVALID_INPUT, + ); + } + + const user = await this.userRepository.findOne({ + where: { id: jwtPayload.sub }, + relations: ['appTokens'], + }); + + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + // Check if revokedAt is less than coolDown + if ( + token.revokedAt && + token.revokedAt.getTime() <= Date.now() - ms(coolDown) + ) { + // Revoke all user refresh tokens + await Promise.all( + user.appTokens.map(async ({ id, type }) => { + if (type === AppTokenType.RefreshToken) { + await this.appTokenRepository.update( + { id }, + { + revokedAt: new Date(), + }, + ); + } + }), + ); + + throw new AuthException( + 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + return { user, token }; + } + + async generateRefreshToken( + userId: string, + workspaceId: string, + ): Promise { + const secret = this.jwtWrapperService.generateAppSecret( + 'REFRESH', + workspaceId, + ); + const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const refreshTokenPayload = { + userId, + expiresAt, + type: AppTokenType.RefreshToken, + }; + const jwtPayload = { + sub: userId, + }; + + const refreshToken = this.appTokenRepository.create(refreshTokenPayload); + + await this.appTokenRepository.save(refreshToken); + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + // Jwtid will be used to link RefreshToken entity to this token + jwtid: refreshToken.id, + }), + expiresAt, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts deleted file mode 100644 index 7bc44ffd001f..000000000000 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import crypto from 'crypto'; - -import { IsNull, MoreThan, Repository } from 'typeorm'; - -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; -import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; -import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { EmailService } from 'src/engine/core-modules/email/email.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; - -import { TokenService } from './token.service'; - -describe('TokenService', () => { - let service: TokenService; - let environmentService: EnvironmentService; - let userRepository: Repository; - let appTokenRepository: Repository; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TokenService, - { - provide: JwtWrapperService, - useValue: {}, - }, - { - provide: JwtAuthStrategy, - useValue: {}, - }, - { - provide: EnvironmentService, - useValue: { - get: jest.fn().mockReturnValue('some-value'), - }, - }, - { - provide: EmailService, - useValue: { - send: jest.fn(), - }, - }, - { - provide: SSOService, - useValue: { - send: jest.fn(), - }, - }, - { - provide: getRepositoryToken(User, 'core'), - useValue: { - findOneBy: jest.fn(), - }, - }, - { - provide: getRepositoryToken(AppToken, 'core'), - useValue: { - findOne: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: getRepositoryToken(Workspace, 'core'), - useValue: {}, - }, - { - provide: TwentyORMGlobalManager, - useValue: {}, - }, - ], - }).compile(); - - service = module.get(TokenService); - environmentService = module.get(EnvironmentService); - userRepository = module.get(getRepositoryToken(User, 'core')); - appTokenRepository = module.get(getRepositoryToken(AppToken, 'core')); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('generatePasswordResetToken', () => { - it('should generate a new password reset token when no existing token is found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - const expiresIn = '3600000'; // 1 hour in ms - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - jest.spyOn(environmentService, 'get').mockReturnValue(expiresIn); - jest - .spyOn(appTokenRepository, 'save') - .mockImplementation(async (token) => token as AppToken); - - const result = await service.generatePasswordResetToken(mockUser.email); - - expect(userRepository.findOneBy).toHaveBeenCalledWith({ - email: mockUser.email, - }); - expect(appTokenRepository.findOne).toHaveBeenCalled(); - expect(appTokenRepository.save).toHaveBeenCalled(); - expect(result.passwordResetToken).toBeDefined(); - expect(result.passwordResetTokenExpiresAt).toBeDefined(); - }); - - it('should throw AuthException if an existing valid token is found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - const mockToken = { - userId: '1', - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // expires 10 seconds in the future - } as AppToken; - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(mockToken); - jest.spyOn(environmentService, 'get').mockReturnValue('3600000'); - - await expect( - service.generatePasswordResetToken(mockUser.email), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if no user is found', async () => { - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); - - await expect( - service.generatePasswordResetToken('nonexistent@example.com'), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if environment variable is not found', async () => { - const mockUser = { id: '1', email: 'test@example.com' } as User; - - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); - jest.spyOn(environmentService, 'get').mockReturnValue(''); // No environment variable set - - await expect( - service.generatePasswordResetToken(mockUser.email), - ).rejects.toThrow(AuthException); - }); - }); - - describe('validatePasswordResetToken', () => { - it('should return user data for a valid and active token', async () => { - const resetToken = 'valid-reset-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: '1', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // Valid future date - }; - const mockUser = { id: '1', email: 'user@example.com' }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - jest - .spyOn(userRepository, 'findOneBy') - .mockResolvedValue(mockUser as User); - - const result = await service.validatePasswordResetToken(resetToken); - - expect(appTokenRepository.findOne).toHaveBeenCalledWith({ - where: { - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - expect(userRepository.findOneBy).toHaveBeenCalledWith({ - id: mockToken.userId, - }); - expect(result).toEqual({ id: mockUser.id, email: mockUser.email }); - }); - - it('should throw AuthException if token is invalid or expired', async () => { - const resetToken = 'invalid-reset-token'; - - jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); - - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if user does not exist for a valid token', async () => { - const resetToken = 'orphan-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: 'nonexistent-user', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), // Valid future date - revokedAt: null, - }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); - - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - - it('should throw AuthException if token is revoked', async () => { - const resetToken = 'revoked-token'; - const hashedToken = crypto - .createHash('sha256') - .update(resetToken) - .digest('hex'); - const mockToken = { - userId: '1', - value: hashedToken, - type: AppTokenType.PasswordResetToken, - expiresAt: new Date(Date.now() + 10000), - revokedAt: new Date(), - }; - - jest - .spyOn(appTokenRepository, 'findOne') - .mockResolvedValue(mockToken as AppToken); - await expect( - service.validatePasswordResetToken(resetToken), - ).rejects.toThrow(AuthException); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index 60abdc4fcd49..e96dcff2b92c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -3,11 +3,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import crypto from 'crypto'; -import { addMilliseconds, differenceInMilliseconds } from 'date-fns'; +import { addMilliseconds } from 'date-fns'; import { Request } from 'express'; import ms from 'ms'; import { ExtractJwt } from 'passport-jwt'; -import { IsNull, MoreThan, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { AppToken, @@ -22,16 +22,13 @@ import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange import { ApiKeyToken, AuthToken, - AuthTokens, - PasswordResetToken, } from 'src/engine/core-modules/auth/dto/token.entity'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { generateSecret } from 'src/utils/generate-secret'; @Injectable() @@ -43,56 +40,11 @@ export class TokenService { private readonly userRepository: Repository, @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, - private readonly sSSOService: SSOService, private readonly accessTokenService: AccessTokenService, private readonly loginTokenService: LoginTokenService, + private readonly refreshTokenService: RefreshTokenService, ) {} - async generateRefreshToken( - userId: string, - workspaceId: string, - ): Promise { - const secret = this.jwtWrapperService.generateAppSecret( - 'REFRESH', - workspaceId, - ); - const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const refreshTokenPayload = { - userId, - expiresAt, - type: AppTokenType.RefreshToken, - }; - const jwtPayload = { - sub: userId, - }; - - const refreshToken = this.appTokenRepository.create(refreshTokenPayload); - - await this.appTokenRepository.save(refreshToken); - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - // Jwtid will be used to link RefreshToken entity to this token - jwtid: refreshToken.id, - }), - expiresAt, - }; - } - async generateInvitationToken(workspaceId: string, email: string) { const expiresIn = this.environmentService.get( 'INVITATION_TOKEN_EXPIRES_IN', @@ -205,90 +157,6 @@ export class TokenService { }; } - async switchWorkspace(user: User, workspaceId: string) { - const userExists = await this.userRepository.findBy({ id: user.id }); - - if (!userExists) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const workspace = await this.workspaceRepository.findOne({ - where: { id: workspaceId }, - relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'], - }); - - if (!workspace) { - throw new AuthException( - 'workspace doesnt exist', - AuthExceptionCode.INVALID_INPUT, - ); - } - - if ( - !workspace.workspaceUsers - .map((userWorkspace) => userWorkspace.userId) - .includes(user.id) - ) { - throw new AuthException( - 'user does not belong to workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - if (workspace.workspaceSSOIdentityProviders.length > 0) { - return { - useSSOAuth: true, - workspace, - availableSSOIdentityProviders: - await this.sSSOService.listSSOIdentityProvidersByWorkspaceId( - workspaceId, - ), - } as { - useSSOAuth: true; - workspace: Workspace; - availableSSOIdentityProviders: Awaited< - ReturnType< - typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId - > - >; - }; - } - - return { - useSSOAuth: false, - workspace, - } as { - useSSOAuth: false; - workspace: Workspace; - }; - } - - async generateSwitchWorkspaceToken( - user: User, - workspace: Workspace, - ): Promise { - await this.userRepository.save({ - id: user.id, - defaultWorkspace: workspace, - }); - - const token = await this.accessTokenService.generateAccessToken( - user.id, - workspace.id, - ); - const refreshToken = await this.generateRefreshToken(user.id, workspace.id); - - return { - tokens: { - accessToken: token, - refreshToken, - }, - }; - } - async verifyAuthorizationCode( exchangeAuthCodeInput: ExchangeAuthCodeInput, ): Promise { @@ -398,7 +266,7 @@ export class TokenService { user.id, user.defaultWorkspaceId, ); - const refreshToken = await this.generateRefreshToken( + const refreshToken = await this.refreshTokenService.generateRefreshToken( user.id, user.defaultWorkspaceId, ); @@ -413,70 +281,6 @@ export class TokenService { }; } - async verifyRefreshToken(refreshToken: string) { - const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); - - await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH'); - const jwtPayload = await this.jwtWrapperService.decode(refreshToken); - - if (!(jwtPayload.jti && jwtPayload.sub)) { - throw new AuthException( - 'This refresh token is malformed', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const token = await this.appTokenRepository.findOneBy({ - id: jwtPayload.jti, - }); - - if (!token) { - throw new AuthException( - "This refresh token doesn't exist", - AuthExceptionCode.INVALID_INPUT, - ); - } - - const user = await this.userRepository.findOne({ - where: { id: jwtPayload.sub }, - relations: ['appTokens'], - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - // Check if revokedAt is less than coolDown - if ( - token.revokedAt && - token.revokedAt.getTime() <= Date.now() - ms(coolDown) - ) { - // Revoke all user refresh tokens - await Promise.all( - user.appTokens.map(async ({ id, type }) => { - if (type === AppTokenType.RefreshToken) { - await this.appTokenRepository.update( - { id }, - { - revokedAt: new Date(), - }, - ); - } - }), - ); - - throw new AuthException( - 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - return { user, token }; - } - async generateTokensFromRefreshToken(token: string): Promise<{ accessToken: AuthToken; refreshToken: AuthToken; @@ -491,7 +295,7 @@ export class TokenService { const { user, token: { id, workspaceId }, - } = await this.verifyRefreshToken(token); + } = await this.refreshTokenService.verifyRefreshToken(token); // Revoke old refresh token await this.appTokenRepository.update( @@ -507,7 +311,10 @@ export class TokenService { user.id, workspaceId, ); - const refreshToken = await this.generateRefreshToken(user.id, workspaceId); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + workspaceId, + ); return { accessToken, @@ -520,69 +327,4 @@ export class TokenService { 'FRONT_BASE_URL', )}/verify?loginToken=${loginToken}`; } - - async generatePasswordResetToken(email: string): Promise { - const user = await this.userRepository.findOneBy({ - email, - }); - - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const expiresIn = this.environmentService.get( - 'PASSWORD_RESET_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const existingToken = await this.appTokenRepository.findOne({ - where: { - userId: user.id, - type: AppTokenType.PasswordResetToken, - expiresAt: MoreThan(new Date()), - revokedAt: IsNull(), - }, - }); - - if (existingToken) { - const timeToWait = ms( - differenceInMilliseconds(existingToken.expiresAt, new Date()), - { long: true }, - ); - - throw new AuthException( - `Token has already been generated. Please wait for ${timeToWait} to generate again.`, - AuthExceptionCode.INVALID_INPUT, - ); - } - - const plainResetToken = crypto.randomBytes(32).toString('hex'); - const hashedResetToken = crypto - .createHash('sha256') - .update(plainResetToken) - .digest('hex'); - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - await this.appTokenRepository.save({ - userId: user.id, - value: hashedResetToken, - expiresAt, - type: AppTokenType.PasswordResetToken, - }); - - return { - passwordResetToken: plainResetToken, - passwordResetTokenExpiresAt: expiresAt, - }; - } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 084011711679..1731f63f4bb6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -7,6 +7,7 @@ import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; @@ -29,7 +30,13 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s JwtAuthStrategy, AccessTokenService, LoginTokenService, + RefreshTokenService, + ], + exports: [ + TokenService, + AccessTokenService, + LoginTokenService, + RefreshTokenService, ], - exports: [TokenService, AccessTokenService, LoginTokenService], }) export class TokenModule {} From 58b4e1daf854882565002775d797aa53d20ce866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 08:49:37 +0200 Subject: [PATCH 09/17] More refactoring --- .../engine/core-modules/auth/auth.module.ts | 2 + .../engine/core-modules/auth/auth.resolver.ts | 13 +- .../google-apis-auth.controller.ts | 6 +- .../controllers/google-auth.controller.ts | 4 +- .../controllers/microsoft-auth.controller.ts | 2 +- .../auth/controllers/sso-auth.controller.ts | 6 +- ...pis-oauth-exchange-code-for-token.guard.ts | 13 +- .../google-apis-oauth-request-code.guard.ts | 13 +- .../auth/services/auth.service.ts | 6 + .../auth/token/services/token.service.ts | 97 +------------- .../services/transient-token.service.spec.ts | 0 .../token/services/transient-token.service.ts | 70 ++++++++++ .../workspace-invitation.service.spec.ts | 123 ++++++++++++++++-- .../services/workspace-invitation.service.ts | 39 +++++- ...l-hydrate-request-from-token.middleware.ts | 11 +- 15 files changed, 262 insertions(+), 143 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index d003b21ed0be..3202d17234ef 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -19,6 +19,7 @@ import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.a import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -94,6 +95,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; LoginTokenService, ResetPasswordService, SwitchWorkspaceService, + TransientTokenService, ], exports: [TokenService, AccessTokenService, LoginTokenService], }) 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 ed55c42efdc1..50aa38f09533 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 @@ -24,6 +24,7 @@ import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filt import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -57,6 +58,7 @@ export class AuthResolver { private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, private switchWorkspaceService: SwitchWorkspaceService, + private transientTokenService: TransientTokenService, ) {} @UseGuards(CaptchaGuard) @@ -140,11 +142,12 @@ export class AuthResolver { if (!workspaceMember) { return; } - const transientToken = await this.tokenService.generateTransientToken( - workspaceMember.id, - user.id, - user.defaultWorkspaceId, - ); + const transientToken = + await this.transientTokenService.generateTransientToken( + workspaceMember.id, + user.id, + user.defaultWorkspaceId, + ); return { transientToken }; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index 615d4c6071d0..13d6d83dc65a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -17,7 +17,7 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard'; import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -27,7 +27,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding export class GoogleAPIsAuthController { constructor( private readonly googleAPIsService: GoogleAPIsService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, private readonly environmentService: EnvironmentService, private readonly onboardingService: OnboardingService, ) {} @@ -58,7 +58,7 @@ export class GoogleAPIsAuthController { } = user; const { workspaceMemberId, userId, workspaceId } = - await this.tokenService.verifyTransientToken(transientToken); + await this.transientTokenService.verifyTransientToken(transientToken); const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS'); diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index b67676c0b466..f12e28237362 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -16,13 +16,11 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/google') @UseFilters(AuthRestApiExceptionFilter) export class GoogleAuthController { constructor( - private readonly tokenService: TokenService, private readonly loginTokenService: LoginTokenService, private readonly authService: AuthService, ) {} @@ -61,6 +59,6 @@ export class GoogleAuthController { user.email, ); - return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); + return res.redirect(this.authService.computeRedirectURI(loginToken.token)); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index b3488d18a1c7..c29c7eccc1da 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -64,6 +64,6 @@ export class MicrosoftAuthController { user.email, ); - return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); + return res.redirect(this.authService.computeRedirectURI(loginToken.token)); } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index 66a591745d81..0224defaabba 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -25,7 +25,6 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { @@ -40,7 +39,6 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in export class SSOAuthController { constructor( private readonly loginTokenService: LoginTokenService, - private readonly tokenService: TokenService, private readonly authService: AuthService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly environmentService: EnvironmentService, @@ -86,7 +84,7 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.tokenService.computeRedirectURI(loginToken.token), + this.authService.computeRedirectURI(loginToken.token), ); } catch (err) { // TODO: improve error management @@ -101,7 +99,7 @@ export class SSOAuthController { const loginToken = await this.generateLoginToken(req.user); return res.redirect( - this.tokenService.computeRedirectURI(loginToken.token), + this.authService.computeRedirectURI(loginToken.token), ); } catch (err) { // TODO: improve error management diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts index 08baa4ff5a51..8766d7d1742a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard.ts @@ -6,11 +6,11 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( @@ -19,7 +19,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( constructor( private readonly environmentService: EnvironmentService, private readonly featureFlagService: FeatureFlagService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, ) { super(); } @@ -27,9 +27,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard( async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); const state = JSON.parse(request.query.state); - const { workspaceId } = await this.tokenService.verifyTransientToken( - state.transientToken, - ); + const { workspaceId } = + await this.transientTokenService.verifyTransientToken( + state.transientToken, + ); const isGmailSendEmailScopeEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IsGmailSendEmailScopeEnabled, diff --git a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts index 9b0e8f26062a..5ba00c3d9b73 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard.ts @@ -6,18 +6,18 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy'; +import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { constructor( private readonly environmentService: EnvironmentService, private readonly featureFlagService: FeatureFlagService, - private readonly tokenService: TokenService, + private readonly transientTokenService: TransientTokenService, ) { super({ prompt: 'select_account', @@ -27,9 +27,10 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') { async canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); - const { workspaceId } = await this.tokenService.verifyTransientToken( - request.query.transientToken, - ); + const { workspaceId } = + await this.transientTokenService.verifyTransientToken( + request.query.transientToken, + ); const isGmailSendEmailScopeEnabled = await this.featureFlagService.isFeatureEnabled( FeatureFlagKey.IsGmailSendEmailScopeEnabled, 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 82443c135dde..8a99656954a5 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 @@ -398,4 +398,10 @@ export class AuthService { return workspace; } + + computeRedirectURI(loginToken: string): string { + return `${this.environmentService.get( + 'FRONT_BASE_URL', + )}/verify?loginToken=${loginToken}`; + } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts index e96dcff2b92c..87b7eade63a9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts @@ -3,16 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import crypto from 'crypto'; -import { addMilliseconds } from 'date-fns'; -import { Request } from 'express'; -import ms from 'ms'; -import { ExtractJwt } from 'passport-jwt'; import { Repository } from 'typeorm'; -import { - AppToken, - AppTokenType, -} from 'src/engine/core-modules/app-token/app-token.entity'; +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException, AuthExceptionCode, @@ -45,66 +38,6 @@ export class TokenService { private readonly refreshTokenService: RefreshTokenService, ) {} - async generateInvitationToken(workspaceId: string, email: string) { - const expiresIn = this.environmentService.get( - 'INVITATION_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for invitation token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - - const invitationToken = this.appTokenRepository.create({ - workspaceId, - expiresAt, - type: AppTokenType.InvitationToken, - value: crypto.randomBytes(32).toString('hex'), - context: { - email, - }, - }); - - return this.appTokenRepository.save(invitationToken); - } - - async generateTransientToken( - workspaceMemberId: string, - userId: string, - workspaceId: string, - ): Promise { - const secret = generateSecret(workspaceId, 'LOGIN'); - const expiresIn = this.environmentService.get( - 'SHORT_TERM_TOKEN_EXPIRES_IN', - ); - - if (!expiresIn) { - throw new AuthException( - 'Expiration time for access token is not set', - AuthExceptionCode.INTERNAL_SERVER_ERROR, - ); - } - - const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); - const jwtPayload = { - sub: workspaceMemberId, - userId, - workspaceId, - }; - - return { - token: this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - }), - expiresAt, - }; - } - async generateApiKeyToken( workspaceId: string, apiKeyId?: string, @@ -135,28 +68,6 @@ export class TokenService { return { token }; } - isTokenPresent(request: Request): boolean { - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - return !!token; - } - - async verifyTransientToken(transientToken: string): Promise<{ - workspaceMemberId: string; - userId: string; - workspaceId: string; - }> { - await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN'); - - const payload = await this.jwtWrapperService.decode(transientToken); - - return { - workspaceMemberId: payload.sub, - userId: payload.userId, - workspaceId: payload.workspaceId, - }; - } - async verifyAuthorizationCode( exchangeAuthCodeInput: ExchangeAuthCodeInput, ): Promise { @@ -321,10 +232,4 @@ export class TokenService { refreshToken, }; } - - computeRedirectURI(loginToken: string): string { - return `${this.environmentService.get( - 'FRONT_BASE_URL', - )}/verify?loginToken=${loginToken}`; - } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts new file mode 100644 index 000000000000..d61305f66e0e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { generateSecret } from 'src/utils/generate-secret'; + +@Injectable() +export class TransientTokenService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateTransientToken( + workspaceMemberId: string, + userId: string, + workspaceId: string, + ): Promise { + const secret = generateSecret(workspaceId, 'LOGIN'); + const expiresIn = this.environmentService.get( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for access token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + const jwtPayload = { + sub: workspaceMemberId, + userId, + workspaceId, + }; + + return { + token: this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + }), + expiresAt, + }; + } + + async verifyTransientToken(transientToken: string): Promise<{ + workspaceMemberId: string; + userId: string; + workspaceId: string; + }> { + await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN'); + + const payload = await this.jwtWrapperService.decode(transientToken); + + return { + workspaceMemberId: payload.sub, + userId: payload.userId, + workspaceId: payload.workspaceId, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 3fce16c4c34b..65340e288e74 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -1,17 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceInvitationService } from './workspace-invitation.service'; describe('WorkspaceInvitationService', () => { let service: WorkspaceInvitationService; + let appTokenRepository: Repository; + let userWorkspaceRepository: Repository; + let environmentService: EnvironmentService; + let emailService: EmailService; + let onboardingService: OnboardingService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,27 +28,29 @@ describe('WorkspaceInvitationService', () => { WorkspaceInvitationService, { provide: getRepositoryToken(AppToken, 'core'), - useValue: {}, - }, - { - provide: EnvironmentService, - useValue: {}, + useClass: Repository, }, { - provide: EmailService, - useValue: {}, + provide: getRepositoryToken(UserWorkspace, 'core'), + useClass: Repository, }, { - provide: TokenService, - useValue: {}, + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, }, { - provide: getRepositoryToken(UserWorkspace, 'core'), - useValue: {}, + provide: EmailService, + useValue: { + send: jest.fn(), + }, }, { provide: OnboardingService, - useValue: {}, + useValue: { + setOnboardingInviteTeamPending: jest.fn(), + }, }, ], }).compile(); @@ -47,9 +58,95 @@ describe('WorkspaceInvitationService', () => { service = module.get( WorkspaceInvitationService, ); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + userWorkspaceRepository = module.get>( + getRepositoryToken(UserWorkspace, 'core'), + ); + environmentService = module.get(EnvironmentService); + emailService = module.get(EmailService); + onboardingService = module.get(OnboardingService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('createWorkspaceInvitation', () => { + it('should create a workspace invitation successfully', async () => { + const email = 'test@example.com'; + const workspace = { id: 'workspace-id' } as Workspace; + + jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + } as any); + + jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false); + jest + .spyOn(service, 'generateInvitationToken') + .mockResolvedValue({} as AppToken); + + await expect( + service.createWorkspaceInvitation(email, workspace), + ).resolves.not.toThrow(); + }); + + it('should throw an exception if invitation already exists', async () => { + const email = 'test@example.com'; + const workspace = { id: 'workspace-id' } as Workspace; + + jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({}), + } as any); + + await expect( + service.createWorkspaceInvitation(email, workspace), + ).rejects.toThrow(WorkspaceInvitationException); + }); + }); + + describe('sendInvitations', () => { + it('should send invitations successfully', async () => { + const emails = ['test1@example.com', 'test2@example.com']; + const workspace = { + id: 'workspace-id', + inviteHash: 'invite-hash', + displayName: 'Test Workspace', + } as Workspace; + const sender = { email: 'sender@example.com', firstName: 'Sender' }; + + jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({ + context: { email: 'test@example.com' }, + value: 'token-value', + } as AppToken); + jest + .spyOn(environmentService, 'get') + .mockReturnValue('http://localhost:3000'); + jest.spyOn(emailService, 'send').mockResolvedValue({} as any); + jest + .spyOn(onboardingService, 'setOnboardingInviteTeamPending') + .mockResolvedValue({} as any); + + const result = await service.sendInvitations( + emails, + workspace, + sender as User, + ); + + expect(result.success).toBe(true); + expect(result.result.length).toBe(2); + expect(emailService.send).toHaveBeenCalledTimes(2); + expect( + onboardingService.setOnboardingInviteTeamPending, + ).toHaveBeenCalledWith({ + workspaceId: workspace.id, + value: false, + }); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 7e7f8cf1f79b..0e1025e8e1fc 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import crypto from 'crypto'; + import { render } from '@react-email/render'; +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; import { SendInviteLinkEmail } from 'twenty-emails'; import { IsNull, Repository } from 'typeorm'; @@ -9,7 +13,10 @@ import { AppToken, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -30,7 +37,6 @@ export class WorkspaceInvitationService { private readonly appTokenRepository: Repository, private readonly environmentService: EnvironmentService, private readonly emailService: EmailService, - private readonly tokenService: TokenService, @InjectRepository(UserWorkspace, 'core') private readonly userWorkspaceRepository: Repository, private readonly onboardingService: OnboardingService, @@ -103,7 +109,7 @@ export class WorkspaceInvitationService { ); } - return this.tokenService.generateInvitationToken(workspace.id, email); + return this.generateInvitationToken(workspace.id, email); } async loadWorkspaceInvitations(workspace: Workspace) { @@ -290,4 +296,31 @@ export class WorkspaceInvitationService { ...result, }; } + + async generateInvitationToken(workspaceId: string, email: string) { + const expiresIn = this.environmentService.get( + 'INVITATION_TOKEN_EXPIRES_IN', + ); + + if (!expiresIn) { + throw new AuthException( + 'Expiration time for invitation token is not set', + AuthExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const invitationToken = this.appTokenRepository.create({ + workspaceId, + expiresAt, + type: AppTokenType.InvitationToken, + value: crypto.randomBytes(32).toString('hex'), + context: { + email, + }, + }); + + return this.appTokenRepository.save(invitationToken); + } } diff --git a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts index 606549cf4f25..1b6b19e7aba5 100644 --- a/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts +++ b/packages/twenty-server/src/engine/middlewares/graphql-hydrate-request-from-token.middleware.ts @@ -1,10 +1,10 @@ import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; +import { ExtractJwt } from 'passport-jwt'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util'; @@ -32,7 +32,6 @@ export class GraphQLHydrateRequestFromTokenMiddleware implements NestMiddleware { constructor( - private readonly tokenService: TokenService, private readonly accessTokenService: AccessTokenService, private readonly workspaceStorageCacheService: WorkspaceCacheStorageService, private readonly exceptionHandlerService: ExceptionHandlerService, @@ -61,7 +60,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware ]; if ( - !this.tokenService.isTokenPresent(req) && + !this.isTokenPresent(req) && (!body?.operationName || excludedOperations.includes(body.operationName)) ) { return next(); @@ -105,4 +104,10 @@ export class GraphQLHydrateRequestFromTokenMiddleware next(); } + + isTokenPresent(request: Request): boolean { + const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + + return !!token; + } } From a6d0c637c6ced5c9a0ba796784e39216c5cf6c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 09:20:22 +0200 Subject: [PATCH 10/17] Refactoring --- .../graphql-config/graphql-config.service.ts | 2 - .../engine/core-modules/auth/auth.module.ts | 8 +- .../engine/core-modules/auth/auth.resolver.ts | 14 ++- .../controllers/microsoft-auth.controller.ts | 4 - .../auth/services/api-key.service.spec.ts | 93 ++++++++++++++ .../auth/services/api-key.service.ts | 44 +++++++ .../oauth.service.ts} | 84 +------------ .../services/renew-token.service.spec.ts | 119 ++++++++++++++++++ .../token/services/renew-token.service.ts | 64 ++++++++++ .../core-modules/auth/token/token.module.ts | 6 +- 10 files changed, 339 insertions(+), 99 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts rename packages/twenty-server/src/engine/core-modules/auth/{token/services/token.service.ts => services/oauth.service.ts} (69%) create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 572531308178..5449caba7a0d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -14,7 +14,6 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -36,7 +35,6 @@ export class GraphQLConfigService implements GqlOptionsFactory> { constructor( - private readonly tokenService: TokenService, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly environmentService: EnvironmentService, private readonly moduleRef: ModuleRef, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 3202d17234ef..9386c26690c1 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -11,14 +11,15 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller'; import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller'; import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller'; +import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; +import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -88,7 +89,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; JwtAuthStrategy, SamlAuthStrategy, AuthResolver, - TokenService, GoogleAPIsService, AppTokenService, AccessTokenService, @@ -96,7 +96,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; ResetPasswordService, SwitchWorkspaceService, TransientTokenService, + ApiKeyService, + OAuthService, ], - exports: [TokenService, AccessTokenService, LoginTokenService], + exports: [AccessTokenService, LoginTokenService], }) export class AuthModule {} 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 50aa38f09533..d819bc84c582 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 @@ -21,9 +21,12 @@ import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/d import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service'; +import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -46,19 +49,20 @@ import { VerifyInput } from './dto/verify.input'; import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input'; import { AuthService } from './services/auth.service'; -import { TokenService } from './token/services/token.service'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter) export class AuthResolver { constructor( private authService: AuthService, - private tokenService: TokenService, + private renewTokenService: RenewTokenService, private userService: UserService, + private apiKeyService: ApiKeyService, private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, private switchWorkspaceService: SwitchWorkspaceService, private transientTokenService: TransientTokenService, + private oauthService: OAuthService, ) {} @UseGuards(CaptchaGuard) @@ -121,7 +125,7 @@ export class AuthResolver { async exchangeAuthorizationCode( @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput, ) { - const tokens = await this.tokenService.verifyAuthorizationCode( + const tokens = await this.oauthService.verifyAuthorizationCode( exchangeAuthCodeInput, ); @@ -217,7 +221,7 @@ export class AuthResolver { @Mutation(() => AuthTokens) async renewToken(@Args() args: AppTokenInput): Promise { - const tokens = await this.tokenService.generateTokensFromRefreshToken( + const tokens = await this.renewTokenService.generateTokensFromRefreshToken( args.appToken, ); @@ -239,7 +243,7 @@ export class AuthResolver { @Args() args: ApiKeyTokenInput, @AuthWorkspace() { id: workspaceId }: Workspace, ): Promise { - return await this.tokenService.generateApiKeyToken( + return await this.apiKeyService.generateApiKeyToken( workspaceId, args.apiKeyId, args.expiresAt, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index c29c7eccc1da..fdfd319fff2e 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -9,22 +9,18 @@ import { import { Response } from 'express'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard'; import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; @Controller('auth/microsoft') @UseFilters(AuthRestApiExceptionFilter) export class MicrosoftAuthController { constructor( private readonly loginTokenService: LoginTokenService, - private readonly tokenService: TokenService, - private readonly typeORMService: TypeORMService, private readonly authService: AuthService, ) {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts new file mode 100644 index 000000000000..6acb921fca25 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts @@ -0,0 +1,93 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { ApiKeyService } from './api-key.service'; + +jest.mock('src/utils/generate-secret', () => ({ + generateSecret: jest.fn().mockReturnValue('mocked-secret'), +})); + +describe('ApiKeyService', () => { + let service: ApiKeyService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ApiKeyService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateApiKeyToken', () => { + it('should return undefined if apiKeyId is not provided', async () => { + const result = await service.generateApiKeyToken('workspace-id'); + + expect(result).toBeUndefined(); + }); + + it('should generate an API key token successfully', async () => { + const workspaceId = 'workspace-id'; + const apiKeyId = 'api-key-id'; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockReturnValue('1h'); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateApiKeyToken(workspaceId, apiKeyId); + + expect(result).toEqual({ token: mockToken }); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: workspaceId }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: '1h', + jwtid: apiKeyId, + }), + ); + }); + + it('should use custom expiration time if provided', async () => { + const workspaceId = 'workspace-id'; + const apiKeyId = 'api-key-id'; + const expiresAt = new Date(Date.now() + 3600000); // 1 hour from now + const mockToken = 'mock-token'; + + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt); + + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: workspaceId }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: expect.any(Number), + jwtid: apiKeyId, + }), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts new file mode 100644 index 000000000000..b9a2008b03bf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { generateSecret } from 'src/utils/generate-secret'; + +@Injectable() +export class ApiKeyService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly environmentService: EnvironmentService, + ) {} + + async generateApiKeyToken( + workspaceId: string, + apiKeyId?: string, + expiresAt?: Date | string, + ): Promise | undefined> { + if (!apiKeyId) { + return; + } + const jwtPayload = { + sub: workspaceId, + }; + const secret = generateSecret(workspaceId, 'ACCESS'); + let expiresIn: string | number; + + if (expiresAt) { + expiresIn = Math.floor( + (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, + ); + } else { + expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN'); + } + const token = this.jwtWrapperService.sign(jwtPayload, { + secret, + expiresIn, + jwtid: apiKeyId, + }); + + return { token }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts similarity index 69% rename from packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts rename to packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts index 87b7eade63a9..83f7b0ff7bc7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/oauth.service.ts @@ -12,62 +12,23 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; -import { - ApiKeyToken, - AuthToken, -} from 'src/engine/core-modules/auth/dto/token.entity'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { generateSecret } from 'src/utils/generate-secret'; @Injectable() -export class TokenService { +export class OAuthService { constructor( - private readonly jwtWrapperService: JwtWrapperService, - private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, private readonly accessTokenService: AccessTokenService, - private readonly loginTokenService: LoginTokenService, private readonly refreshTokenService: RefreshTokenService, + private readonly loginTokenService: LoginTokenService, ) {} - async generateApiKeyToken( - workspaceId: string, - apiKeyId?: string, - expiresAt?: Date | string, - ): Promise | undefined> { - if (!apiKeyId) { - return; - } - const jwtPayload = { - sub: workspaceId, - }; - const secret = generateSecret(workspaceId, 'ACCESS'); - let expiresIn: string | number; - - if (expiresAt) { - expiresIn = Math.floor( - (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, - ); - } else { - expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN'); - } - const token = this.jwtWrapperService.sign(jwtPayload, { - secret, - expiresIn, - jwtid: apiKeyId, - }); - - return { token }; - } - async verifyAuthorizationCode( exchangeAuthCodeInput: ExchangeAuthCodeInput, ): Promise { @@ -191,45 +152,4 @@ export class TokenService { loginToken, }; } - - async generateTokensFromRefreshToken(token: string): Promise<{ - accessToken: AuthToken; - refreshToken: AuthToken; - }> { - if (!token) { - throw new AuthException( - 'Refresh token not found', - AuthExceptionCode.INVALID_INPUT, - ); - } - - const { - user, - token: { id, workspaceId }, - } = await this.refreshTokenService.verifyRefreshToken(token); - - // Revoke old refresh token - await this.appTokenRepository.update( - { - id, - }, - { - revokedAt: new Date(), - }, - ); - - const accessToken = await this.accessTokenService.generateAccessToken( - user.id, - workspaceId, - ); - const refreshToken = await this.refreshTokenService.generateRefreshToken( - user.id, - workspaceId, - ); - - return { - accessToken, - refreshToken, - }; - } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts new file mode 100644 index 000000000000..d44fb5473d1b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; + +import { RenewTokenService } from './renew-token.service'; + +describe('RenewTokenService', () => { + let service: RenewTokenService; + let appTokenRepository: Repository; + let accessTokenService: AccessTokenService; + let refreshTokenService: RefreshTokenService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RenewTokenService, + { + provide: getRepositoryToken(AppToken, 'core'), + useClass: Repository, + }, + { + provide: AccessTokenService, + useValue: { + generateAccessToken: jest.fn(), + }, + }, + { + provide: RefreshTokenService, + useValue: { + verifyRefreshToken: jest.fn(), + generateRefreshToken: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RenewTokenService); + appTokenRepository = module.get>( + getRepositoryToken(AppToken, 'core'), + ); + accessTokenService = module.get(AccessTokenService); + refreshTokenService = module.get(RefreshTokenService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateTokensFromRefreshToken', () => { + it('should generate new access and refresh tokens', async () => { + const mockRefreshToken = 'valid-refresh-token'; + const mockUser = { id: 'user-id' } as User; + const mockWorkspaceId = 'workspace-id'; + const mockTokenId = 'token-id'; + const mockAccessToken = { + token: 'new-access-token', + expiresAt: new Date(), + }; + const mockNewRefreshToken = { + token: 'new-refresh-token', + expiresAt: new Date(), + }; + const mockAppToken: Partial = { + id: mockTokenId, + workspaceId: mockWorkspaceId, + user: mockUser, + userId: mockUser.id, + }; + + jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({ + user: mockUser, + token: mockAppToken as AppToken, + }); + jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any); + jest + .spyOn(accessTokenService, 'generateAccessToken') + .mockResolvedValue(mockAccessToken); + jest + .spyOn(refreshTokenService, 'generateRefreshToken') + .mockResolvedValue(mockNewRefreshToken); + + const result = + await service.generateTokensFromRefreshToken(mockRefreshToken); + + expect(result).toEqual({ + accessToken: mockAccessToken, + refreshToken: mockNewRefreshToken, + }); + expect(refreshTokenService.verifyRefreshToken).toHaveBeenCalledWith( + mockRefreshToken, + ); + expect(appTokenRepository.update).toHaveBeenCalledWith( + { id: mockTokenId }, + { revokedAt: expect.any(Date) }, + ); + expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspaceId, + ); + expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith( + mockUser.id, + mockWorkspaceId, + ); + }); + + it('should throw an error if refresh token is not provided', async () => { + await expect(service.generateTokensFromRefreshToken('')).rejects.toThrow( + AuthException, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts new file mode 100644 index 000000000000..a06e4790fb3a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; + +@Injectable() +export class RenewTokenService { + constructor( + @InjectRepository(AppToken, 'core') + private readonly appTokenRepository: Repository, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + async generateTokensFromRefreshToken(token: string): Promise<{ + accessToken: AuthToken; + refreshToken: AuthToken; + }> { + if (!token) { + throw new AuthException( + 'Refresh token not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + const { + user, + token: { id, workspaceId }, + } = await this.refreshTokenService.verifyRefreshToken(token); + + // Revoke old refresh token + await this.appTokenRepository.update( + { + id, + }, + { + revokedAt: new Date(), + }, + ); + + const accessToken = await this.accessTokenService.generateAccessToken( + user.id, + workspaceId, + ); + const refreshToken = await this.refreshTokenService.generateRefreshToken( + user.id, + workspaceId, + ); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 1731f63f4bb6..c39ac8aa5739 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -8,7 +8,7 @@ import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.aut import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; @@ -26,14 +26,14 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s WorkspaceSSOModule, ], providers: [ - TokenService, + RenewTokenService, JwtAuthStrategy, AccessTokenService, LoginTokenService, RefreshTokenService, ], exports: [ - TokenService, + RenewTokenService, AccessTokenService, LoginTokenService, RefreshTokenService, From 936bc38720496b129d0d80e1b24f45cf9222acfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 09:38:38 +0200 Subject: [PATCH 11/17] Fix a few tests --- .../core-modules/auth/auth.resolver.spec.ts | 38 +++++++++++++++++-- .../services/reset-password.service.spec.ts | 8 +++- .../auth/services/reset-password.service.ts | 2 - 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 2d08a6b4ead4..877cb8c049c2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -10,8 +10,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthResolver } from './auth.resolver'; +import { ApiKeyService } from './services/api-key.service'; import { AuthService } from './services/auth.service'; -import { TokenService } from './token/services/token.service'; +import { OAuthService } from './services/oauth.service'; +import { ResetPasswordService } from './services/reset-password.service'; +import { SwitchWorkspaceService } from './services/switch-workspace.service'; +import { LoginTokenService } from './token/services/login-token.service'; +import { RenewTokenService } from './token/services/renew-token.service'; +import { TransientTokenService } from './token/services/transient-token.service'; describe('AuthResolver', () => { let resolver: AuthResolver; @@ -34,15 +40,39 @@ describe('AuthResolver', () => { useValue: {}, }, { - provide: TokenService, + provide: UserService, useValue: {}, }, { - provide: UserService, + provide: UserWorkspaceService, useValue: {}, }, { - provide: UserWorkspaceService, + provide: RenewTokenService, + useValue: {}, + }, + { + provide: ApiKeyService, + useValue: {}, + }, + { + provide: ResetPasswordService, + useValue: {}, + }, + { + provide: LoginTokenService, + useValue: {}, + }, + { + provide: SwitchWorkspaceService, + useValue: {}, + }, + { + provide: TransientTokenService, + useValue: {}, + }, + { + provide: OAuthService, useValue: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts index f6500022e64b..e0b81f69d418 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts @@ -12,6 +12,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { ResetPasswordService } from './reset-password.service'; @@ -34,10 +35,14 @@ describe('ResetPasswordService', () => { provide: getRepositoryToken(AppToken, 'core'), useClass: Repository, }, + { + provide: getRepositoryToken(Workspace, 'core'), + useClass: Repository, + }, { provide: EmailService, useValue: { - send: jest.fn(), + send: jest.fn().mockResolvedValue({ success: true }), }, }, { @@ -131,7 +136,6 @@ describe('ResetPasswordService', () => { jest .spyOn(environmentService, 'get') .mockReturnValue('http://localhost:3000'); - jest.spyOn(emailService, 'send').mockResolvedValue({} as any); const result = await service.sendEmailPasswordResetLink( mockToken, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts index 79a73a1c0441..f07c45d7d698 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts @@ -24,7 +24,6 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class ResetPasswordService { @@ -34,7 +33,6 @@ export class ResetPasswordService { private readonly userRepository: Repository, @InjectRepository(AppToken, 'core') private readonly appTokenRepository: Repository, - @InjectRepository(Workspace, 'core') private readonly emailService: EmailService, ) {} From 00e5c9a53884a82b78cceb819b2a80e629f99f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 11:06:42 +0200 Subject: [PATCH 12/17] Fix a few tests --- .../auth/services/auth.service.spec.ts | 25 ++++++++----------- .../workspace-invitation.service.spec.ts | 6 ++++- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index f52023891f6c..5d7ebc9a4ebf 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -3,13 +3,12 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { AuthService } from './auth.service'; @@ -21,39 +20,35 @@ describe('AuthService', () => { providers: [ AuthService, { - provide: TokenService, - useValue: {}, - }, - { - provide: UserService, + provide: getRepositoryToken(Workspace, 'core'), useValue: {}, }, { - provide: SignInUpService, + provide: getRepositoryToken(User, 'core'), useValue: {}, }, { - provide: WorkspaceManagerService, + provide: getRepositoryToken(AppToken, 'core'), useValue: {}, }, { - provide: getRepositoryToken(Workspace, 'core'), + provide: SignInUpService, useValue: {}, }, { - provide: getRepositoryToken(User, 'core'), + provide: EnvironmentService, useValue: {}, }, { - provide: getRepositoryToken(AppToken, 'core'), + provide: EmailService, useValue: {}, }, { - provide: EnvironmentService, + provide: AccessTokenService, useValue: {}, }, { - provide: EmailService, + provide: RefreshTokenService, useValue: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 65340e288e74..5b05d0970284 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -3,7 +3,10 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AppToken, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; @@ -123,6 +126,7 @@ describe('WorkspaceInvitationService', () => { jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({ context: { email: 'test@example.com' }, value: 'token-value', + type: AppTokenType.InvitationToken, } as AppToken); jest .spyOn(environmentService, 'get') From 526cfb6efc768cdf5a57fafd6c55b07ce9484249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 11:43:04 +0200 Subject: [PATCH 13/17] Fix and add more tests --- .../verify-auth.controller.spec.ts | 4 +- .../auth/services/api-key.service.spec.ts | 11 +- .../auth/services/api-key.service.ts | 6 +- .../services/login-token.service.spec.ts | 117 +++++++++++++++ .../services/transient-token.service.spec.ts | 133 ++++++++++++++++++ .../token/services/transient-token.service.ts | 6 +- .../open-api/open-api.service.spec.ts | 4 +- .../src/utils/generate-secret.ts | 22 --- 8 files changed, 269 insertions(+), 34 deletions(-) delete mode 100644 packages/twenty-server/src/utils/generate-secret.ts diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts index a73754fbddbb..11dfd40b6d4d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/verify-auth.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { VerifyAuthController } from './verify-auth.controller'; @@ -17,7 +17,7 @@ describe('VerifyAuthController', () => { useValue: {}, }, { - provide: TokenService, + provide: LoginTokenService, useValue: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts index 6acb921fca25..2028da34e7f6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.spec.ts @@ -5,10 +5,6 @@ import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrap import { ApiKeyService } from './api-key.service'; -jest.mock('src/utils/generate-secret', () => ({ - generateSecret: jest.fn().mockReturnValue('mocked-secret'), -})); - describe('ApiKeyService', () => { let service: ApiKeyService; let jwtWrapperService: JwtWrapperService; @@ -22,6 +18,7 @@ describe('ApiKeyService', () => { provide: JwtWrapperService, useValue: { sign: jest.fn(), + generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), }, }, { @@ -56,6 +53,9 @@ describe('ApiKeyService', () => { jest.spyOn(environmentService, 'get').mockReturnValue('1h'); jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mocked-secret'); const result = await service.generateApiKeyToken(workspaceId, apiKeyId); @@ -77,6 +77,9 @@ describe('ApiKeyService', () => { const mockToken = 'mock-token'; jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue('mocked-secret'); await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts index b9a2008b03bf..288a6aa0525c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/api-key.service.ts @@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common'; import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { generateSecret } from 'src/utils/generate-secret'; @Injectable() export class ApiKeyService { @@ -23,7 +22,10 @@ export class ApiKeyService { const jwtPayload = { sub: workspaceId, }; - const secret = generateSecret(workspaceId, 'ACCESS'); + const secret = this.jwtWrapperService.generateAppSecret( + 'ACCESS', + workspaceId, + ); let expiresIn: string | number; if (expiresAt) { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts index e69de29bb2d1..62d21a673d45 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/login-token.service.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { LoginTokenService } from './login-token.service'; + +describe('LoginTokenService', () => { + let service: LoginTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginTokenService, + { + provide: JwtWrapperService, + useValue: { + generateAppSecret: jest.fn(), + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(LoginTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateLoginToken', () => { + it('should generate a login token successfully', async () => { + const email = 'test@example.com'; + const mockSecret = 'mock-secret'; + const mockExpiresIn = '1h'; + const mockToken = 'mock-token'; + + jest + .spyOn(jwtWrapperService, 'generateAppSecret') + .mockReturnValue(mockSecret); + jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateLoginToken(email); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN'); + expect(environmentService.get).toHaveBeenCalledWith( + 'LOGIN_TOKEN_EXPIRES_IN', + ); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { sub: email }, + { secret: mockSecret, expiresIn: mockExpiresIn }, + ); + }); + + it('should throw an error if LOGIN_TOKEN_EXPIRES_IN is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateLoginToken('test@example.com'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('verifyLoginToken', () => { + it('should verify a login token successfully', async () => { + const mockToken = 'valid-token'; + const mockEmail = 'test@example.com'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest + .spyOn(jwtWrapperService, 'decode') + .mockReturnValue({ sub: mockEmail }); + + const result = await service.verifyLoginToken(mockToken); + + expect(result).toEqual(mockEmail); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'LOGIN', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, { + json: true, + }); + }); + + it('should throw an error if token verification fails', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockRejectedValue(new Error('Invalid token')); + + await expect(service.verifyLoginToken(mockToken)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts index e69de29bb2d1..adf31855ac53 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +import { TransientTokenService } from './transient-token.service'; + +describe('TransientTokenService', () => { + let service: TransientTokenService; + let jwtWrapperService: JwtWrapperService; + let environmentService: EnvironmentService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransientTokenService, + { + provide: JwtWrapperService, + useValue: { + sign: jest.fn(), + verifyWorkspaceToken: jest.fn(), + decode: jest.fn(), + generateAppSecret: jest.fn().mockReturnValue('mocked-secret'), + }, + }, + { + provide: EnvironmentService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(TransientTokenService); + jwtWrapperService = module.get(JwtWrapperService); + environmentService = module.get(EnvironmentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generateTransientToken', () => { + it('should generate a transient token successfully', async () => { + const workspaceMemberId = 'workspace-member-id'; + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const mockExpiresIn = '15m'; + const mockToken = 'mock-token'; + + jest.spyOn(environmentService, 'get').mockImplementation((key) => { + if (key === 'SHORT_TERM_TOKEN_EXPIRES_IN') return mockExpiresIn; + + return undefined; + }); + jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken); + + const result = await service.generateTransientToken( + workspaceMemberId, + userId, + workspaceId, + ); + + expect(result).toEqual({ + token: mockToken, + expiresAt: expect.any(Date), + }); + expect(environmentService.get).toHaveBeenCalledWith( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + expect(jwtWrapperService.sign).toHaveBeenCalledWith( + { + sub: workspaceMemberId, + userId, + workspaceId, + }, + expect.objectContaining({ + secret: 'mocked-secret', + expiresIn: mockExpiresIn, + }), + ); + }); + + it('should throw an error if SHORT_TERM_TOKEN_EXPIRES_IN is not set', async () => { + jest.spyOn(environmentService, 'get').mockReturnValue(undefined); + + await expect( + service.generateTransientToken('member-id', 'user-id', 'workspace-id'), + ).rejects.toThrow(AuthException); + }); + }); + + describe('verifyTransientToken', () => { + it('should verify a transient token successfully', async () => { + const mockToken = 'valid-token'; + const mockPayload = { + sub: 'workspace-member-id', + userId: 'user-id', + workspaceId: 'workspace-id', + }; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockResolvedValue(undefined); + jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload); + + const result = await service.verifyTransientToken(mockToken); + + expect(result).toEqual({ + workspaceMemberId: mockPayload.sub, + userId: mockPayload.userId, + workspaceId: mockPayload.workspaceId, + }); + expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith( + mockToken, + 'LOGIN', + ); + expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken); + }); + + it('should throw an error if token verification fails', async () => { + const mockToken = 'invalid-token'; + + jest + .spyOn(jwtWrapperService, 'verifyWorkspaceToken') + .mockRejectedValue(new Error('Invalid token')); + + await expect(service.verifyTransientToken(mockToken)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts index d61305f66e0e..a9cad6c97ff3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/transient-token.service.ts @@ -10,7 +10,6 @@ import { import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { generateSecret } from 'src/utils/generate-secret'; @Injectable() export class TransientTokenService { @@ -24,7 +23,10 @@ export class TransientTokenService { userId: string, workspaceId: string, ): Promise { - const secret = generateSecret(workspaceId, 'LOGIN'); + const secret = this.jwtWrapperService.generateAppSecret( + 'LOGIN', + workspaceId, + ); const expiresIn = this.environmentService.get( 'SHORT_TERM_TOKEN_EXPIRES_IN', ); diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts index d4dce437eb5f..9ac93d60451c 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service'; +import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -13,7 +13,7 @@ describe('OpenApiService', () => { providers: [ OpenApiService, { - provide: TokenService, + provide: AccessTokenService, useValue: {}, }, { diff --git a/packages/twenty-server/src/utils/generate-secret.ts b/packages/twenty-server/src/utils/generate-secret.ts deleted file mode 100644 index e00ac77c809c..000000000000 --- a/packages/twenty-server/src/utils/generate-secret.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ConfigService } from '@nestjs/config'; - -import { createHash } from 'crypto'; - -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; - -export const generateSecret = ( - workspaceId: string, - type: 'ACCESS' | 'LOGIN' | 'REFRESH' | 'FILE', -): string => { - const appSecret = new EnvironmentService(new ConfigService()).get( - 'APP_SECRET', - ); - - if (!appSecret) { - throw new Error('APP_SECRET is not set'); - } - - return createHash('sha256') - .update(`${appSecret}${workspaceId}${type}`) - .digest('hex'); -}; From d0f475764dc2320619561af83ba325dd21454490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 14:18:00 +0200 Subject: [PATCH 14/17] Fix integration tests --- packages/twenty-docker/.env.example | 2 +- packages/twenty-server/.env.example | 4 ++-- packages/twenty-server/.env.test | 4 ++-- packages/twenty-server/jest-integration.config.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index 2fc006fba62e..b10e09876e52 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -8,7 +8,7 @@ REDIS_URL=redis://redis:6379 SERVER_URL=http://localhost:3000 # Use openssl rand -base64 32 for each secret -# APP_SECRET=replace_me_with_a_random_string_access +# APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index ca48a72441ec..423239557e80 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -1,11 +1,11 @@ # Use this for local setup PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default +REDIS_URL=redis://localhost:6379 FRONT_BASE_URL=http://localhost:3001 -APP_SECRET=replace_me_with_a_random_string_app +APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true -REDIS_URL=redis://localhost:6379 # ———————— Optional ———————— diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test index ba2115883c3b..659411dc21bc 100644 --- a/packages/twenty-server/.env.test +++ b/packages/twenty-server/.env.test @@ -1,9 +1,10 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test +REDIS_URL=redis://localhost:6379 DEBUG_MODE=true DEBUG_PORT=9000 FRONT_BASE_URL=http://localhost:3001 -APP_SECRET=replace_me_with_a_random_string_access +APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true EXCEPTION_HANDLER_DRIVER=console SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944 @@ -11,7 +12,6 @@ DEMO_WORKSPACE_IDS=63db4589-590f-42b3-bdf1-85268b3da02f,8de58f3f-7e86-4a0b-998d- MUTATION_MAXIMUM_RECORD_AFFECTED=100 MESSAGE_QUEUE_TYPE=bull-mq CACHE_STORAGE_TYPE=redis -REDIS_URL=redis://localhost:6379 AUTH_GOOGLE_ENABLED=false MESSAGING_PROVIDER_GMAIL_ENABLED=false diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index deb2ba3ee5a1..9dc26ba51866 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -30,7 +30,7 @@ const jestConfig: JestConfigWithTsJest = { globals: { APP_PORT: 4000, ACCESS_TOKEN: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ.zM6TbfeOqYVH5Sgryc2zf02hd9uqUOSL1-iJlMgwzsI', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ._ISjY_dlVWskeQ6wkE0-kOn641G_mee5GiqoZTQFIfE', }, }; From b2613ba8a9cc97ab6a0df6e37851180a4abbdd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 22 Oct 2024 15:15:20 +0200 Subject: [PATCH 15/17] Fix file token --- .../handlers/activity-query-result-getter.handler.ts | 4 ++-- .../handlers/attachment-query-result-getter.handler.ts | 4 ++-- .../handlers/person-query-result-getter.handler.ts | 4 ++-- .../workspace-member-query-result-getter.handler.ts | 4 ++-- .../file/file-upload/services/file-upload.service.ts | 4 ++-- .../engine/core-modules/file/guards/file-path-guard.ts | 4 ++-- .../engine/core-modules/file/services/file.service.ts | 4 ++-- .../core-modules/jwt/services/jwt-wrapper.service.ts | 3 ++- .../src/engine/core-modules/user/user.resolver.ts | 10 +++++----- .../core-modules/workspace/workspace.resolver.ts | 4 ++-- 10 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts index aa6da51f4e5e..f8dceb09215e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler.ts @@ -35,8 +35,8 @@ export class ActivityQueryResultGetterHandler imageUrl.searchParams.delete('token'); const signedPayload = await this.fileService.encodeFileToken({ - note_block_id: block.id, - workspace_id: workspaceId, + noteBlockId: block.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts index d6e642ba6676..794657c7d269 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class AttachmentQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - attachment_id: attachment.id, - workspace_id: workspaceId, + attachmentId: attachment.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts index 4b6dfb114ae3..50bedd2bbc1c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class PersonQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - person_id: person.id, - workspace_id: workspaceId, + personId: person.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts index 34a94f833b12..46808713fc67 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts @@ -17,8 +17,8 @@ export class WorkspaceMemberQueryResultGetterHandler } const signedPayload = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: workspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index 80cd2ffd9db9..15fadc8b178e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -8,8 +8,8 @@ import { v4 as uuidV4 } from 'uuid'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { settings } from 'src/engine/constants/settings'; -import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { getCropSize } from 'src/utils/image'; @Injectable() @@ -83,7 +83,7 @@ export class FileUploadService { }); const signedPayload = await this.fileService.encodeFileToken({ - workspace_id: workspaceId, + workspaceId: workspaceId, }); return { diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 1cd6ff889c1d..3296a57cf76e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -33,8 +33,8 @@ export class FilePathGuard implements CanActivate { json: true, }); - const expirationDate = decodedPayload?.['expiration_date']; - const workspaceId = decodedPayload?.['workspace_id']; + const expirationDate = decodedPayload?.['expirationDate']; + const workspaceId = decodedPayload?.['workspaceId']; const isExpired = await this.isExpired(expirationDate); diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 45a7a8ad44cf..c4c240f21051 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -36,14 +36,14 @@ export class FileService { ); const secret = this.jwtWrapperService.generateAppSecret( 'FILE', - payloadToEncode.workspace_id, + payloadToEncode.workspaceId, ); const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn)); const signedPayload = this.jwtWrapperService.sign( { - expiration_date: expirationDate, + expirationDate: expirationDate, ...payloadToEncode, }, { diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index e78986b2cadb..d78ba1b4e026 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -52,7 +52,8 @@ export class JwtWrapperService { json: true, }); - if (!payload.sub) { + // TODO: check if this is really needed + if (type !== 'FILE' && !payload.sub) { throw new UnauthorizedException('No payload sub'); } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index 86f27d821b4e..348204c6e2a0 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -110,8 +110,8 @@ export class UserResolver { if (workspaceMember && workspaceMember.avatarUrl) { const avatarUrlToken = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: user.defaultWorkspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: user.defaultWorkspaceId, }); workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; @@ -132,8 +132,8 @@ export class UserResolver { for (const workspaceMember of workspaceMembers) { if (workspaceMember.avatarUrl) { const avatarUrlToken = await this.fileService.encodeFileToken({ - workspace_member_id: workspaceMember.id, - workspace_id: user.defaultWorkspaceId, + workspaceMemberId: workspaceMember.id, + workspaceId: user.defaultWorkspaceId, }); workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`; @@ -189,7 +189,7 @@ export class UserResolver { }); const fileToken = await this.fileService.encodeFileToken({ - workspace_id: workspaceId, + workspaceId: workspaceId, }); return `${paths[0]}?token=${fileToken}`; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 958738005ae6..6fcf2222d4b1 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -92,7 +92,7 @@ export class WorkspaceResolver { }); const workspaceLogoToken = await this.fileService.encodeFileToken({ - workspace_id: id, + workspaceId: id, }); return `${paths[0]}?token=${workspaceLogoToken}`; @@ -125,7 +125,7 @@ export class WorkspaceResolver { if (workspace.logo) { try { const workspaceLogoToken = await this.fileService.encodeFileToken({ - workspace_id: workspace.id, + workspaceId: workspace.id, }); return `${workspace.logo}?token=${workspaceLogoToken}`; From 619b560460abb717bc360efe8f9ad0b1ea93662e Mon Sep 17 00:00:00 2001 From: ZiaCodes Date: Sun, 27 Oct 2024 18:57:57 +0500 Subject: [PATCH 16/17] feat: updated developer docs --- .github/workflows/ci-test-docker-compose.yaml | 5 +-- .../self-hosting/cloud-providers.mdx | 36 ++++--------------- .../self-hosting/docker-compose.mdx | 9 ++--- .../self-hosting/self-hosting-var.mdx | 5 +-- 4 files changed, 12 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index 1496425c8511..5487f3a587b3 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -31,10 +31,7 @@ jobs: cp .env.example .env echo "Generating secrets..." echo "# === Randomly generated secrets ===" >>.env - echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env + echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env echo "Starting server..." diff --git a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx index 08e541a21a19..3882c407b964 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx @@ -5,10 +5,9 @@ image: /images/user-guide/notes/notes_header.png --- -This document is maintained by the community. It might contain issues. + This document is maintained by the community. It might contain issues. - ## Kubernetes via Terraform and Manifests Community-led documentation for Kubernetes deployment is available (here)[https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s] @@ -19,14 +18,12 @@ Community-led, might not be up to date [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/twentyhq/twenty) - -## RepoCloud +## RepoCloud Community-led, might not be up to date [![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=259) - ## Azure Container Apps Community-led, might not be up to date @@ -271,11 +268,8 @@ resource "azapi_update_resource" "cors" { ```hcl # backend.tf -# Create three random UUIDs -resource "random_uuid" "access_token_secret" {} -resource "random_uuid" "login_token_secret" {} -resource "random_uuid" "refresh_token_secret" {} -resource "random_uuid" "file_token_secret" {} +# Create app random UUID +resource "random_uuid" "app_secret" {} resource "azurerm_container_app" "twenty_server" { name = local.server_name @@ -339,24 +333,8 @@ resource "azurerm_container_app" "twenty_server" { ${local.db_password}@${local.db_app_name}:5432/default" } env { - name = "FRONT_BASE_URL" - value = "https://${local.front_app_name}" - } - env { - name = "ACCESS_TOKEN_SECRET" - value = random_uuid.access_token_secret.result - } - env { - name = "LOGIN_TOKEN_SECRET" - value = random_uuid.login_token_secret.result - } - env { - name = "REFRESH_TOKEN_SECRET" - value = random_uuid.refresh_token_secret.result - } - env { - name = "FILE_TOKEN_SECRET" - value = random_uuid.file_token_secret.result + name = "APP_SECRET" + value = random_uuid.app_secret.result } } } @@ -446,4 +424,4 @@ resource "azurerm_container_app" "twenty_db" { Please feel free to Open a PR to add more Cloud Provider options. - \ No newline at end of file + diff --git a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx index 833fffbcc43e..62006ffbffc3 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/docker-compose.mdx @@ -46,7 +46,7 @@ Follow these steps for a manual setup. 2. **Generate Secret Tokens** - Run the following command four times to generate four unique random strings: + Run the following command to generate a unique random string: ```bash openssl rand -base64 32 ``` @@ -54,13 +54,10 @@ Follow these steps for a manual setup. 3. **Update the `.env`** - Replace the placeholder values in your .env file with the generated tokens: + Replace the placeholder value in your .env file with the generated token: ```ini - ACCESS_TOKEN_SECRET=first_random_string - LOGIN_TOKEN_SECRET=second_random_string - REFRESH_TOKEN_SECRET=third_random_string - FILE_TOKEN_SECRET=fourth_random_string + APP_SECRET=first_random_string ``` **Note:** Only modify these lines unless instructed otherwise. diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index cda5fb3a3d1c..b574bb11933b 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -54,14 +54,11 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ### Tokens ', 'Secret used for the access tokens'], + ['APP_SECRET', '', 'Secret used for the app token'], ['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'], - ['LOGIN_TOKEN_SECRET', '', 'Secret used for the login tokens'], ['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'], - ['REFRESH_TOKEN_SECRET', '', 'Secret used for the refresh tokens'], ['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'], ['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'], - ['FILE_TOKEN_SECRET', '', 'Secret used for the file tokens'], ['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'], ['API_TOKEN_EXPIRES_IN', '1000y', 'Api token expiration time'], ]}> From 315cf5b61bb869429e0f500e4beacba7f6033192 Mon Sep 17 00:00:00 2001 From: ZiaCodes Date: Sun, 27 Oct 2024 19:05:42 +0500 Subject: [PATCH 17/17] feat: updated developer docs --- .../src/content/developers/self-hosting/cloud-providers.mdx | 6 +++++- .../content/developers/self-hosting/self-hosting-var.mdx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx index 139c3324bf2e..8c40c0b3137d 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/cloud-providers.mdx @@ -268,7 +268,7 @@ resource "azapi_update_resource" "cors" { ```hcl # backend.tf -# Create app random UUID +# Create a random UUID resource "random_uuid" "app_secret" {} resource "azurerm_container_app" "twenty_server" { @@ -332,6 +332,10 @@ resource "azurerm_container_app" "twenty_server" { value = "postgres://${local.db_user}: ${local.db_password}@${local.db_app_name}:5432/default" } + env { + name = "FRONT_BASE_URL" + value = "https://${local.front_app_name}" + } env { name = "APP_SECRET" value = random_uuid.app_secret.result diff --git a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx index 26b1d2c54fa8..089e801ce6ff 100644 --- a/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx +++ b/packages/twenty-website/src/content/developers/self-hosting/self-hosting-var.mdx @@ -51,7 +51,7 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ### Tokens ', 'Secret used for the app token'], + ['APP_SECRET', '', 'Secret used for encryption across the app'], ['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'], ['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'], ['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'],