diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 423239557e..2006ed2eee 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -7,6 +7,7 @@ FRONT_BASE_URL=http://localhost:3001 APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true +ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access # ———————— Optional ———————— # PORT=3000 @@ -14,7 +15,6 @@ SIGN_IN_PREFILLED=true # DEBUG_PORT=9000 # ACCESS_TOKEN_EXPIRES_IN=30m # LOGIN_TOKEN_EXPIRES_IN=15m -# API_TOKEN_EXPIRES_IN=1000y # REFRESH_TOKEN_EXPIRES_IN=90d # FILE_TOKEN_EXPIRES_IN=1d # FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify 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 2028da34e7..7159269ec8 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 @@ -61,10 +61,14 @@ describe('ApiKeyService', () => { expect(result).toEqual({ token: mockToken }); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: workspaceId }, + { + sub: workspaceId, + type: 'API_KEY', + workspaceId: workspaceId, + }, expect.objectContaining({ secret: 'mocked-secret', - expiresIn: '1h', + expiresIn: '100y', jwtid: apiKeyId, }), ); @@ -84,7 +88,11 @@ describe('ApiKeyService', () => { await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: workspaceId }, + { + sub: workspaceId, + type: 'API_KEY', + workspaceId: workspaceId, + }, expect.objectContaining({ secret: 'mocked-secret', expiresIn: expect.any(Number), 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 288a6aa052..f6bad82221 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 @@ -21,6 +21,8 @@ export class ApiKeyService { } const jwtPayload = { sub: workspaceId, + type: 'API_KEY', + workspaceId, }; const secret = this.jwtWrapperService.generateAppSecret( 'ACCESS', @@ -33,7 +35,7 @@ export class ApiKeyService { (new Date(expiresAt).getTime() - new Date().getTime()) / 1000, ); } else { - expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN'); + expiresIn = '100y'; } const token = this.jwtWrapperService.sign(jwtPayload, { secret, diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts new file mode 100644 index 0000000000..fdc4e06463 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -0,0 +1,185 @@ +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { JwtAuthStrategy } from './jwt.auth.strategy'; + +xdescribe('JwtAuthStrategy', () => { + let strategy: JwtAuthStrategy; + + let workspaceRepository: any; + let userRepository: any; + let dataSourceService: any; + let typeORMService: any; + const jwt = { + sub: 'sub-default', + jti: 'jti-default', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + userRepository = { + findOne: jest.fn(async () => null), + }; + + // first we test the API_KEY case + it('should throw AuthException if type is API_KEY and workspace is not found', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => null), + }; + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + {} as any, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ), + ); + }); + + it('should throw AuthExceptionCode if type is API_KEY not found', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + dataSourceService = { + getLastDataSourceMetadataFromWorkspaceIdOrFail: jest.fn(async () => ({})), + }; + + typeORMService = { + connectToDataSource: jest.fn(async () => {}), + }; + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + {} as any, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException( + 'This API Key is revoked', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ), + ); + }); + + it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => { + const payload = { + ...jwt, + type: 'API_KEY', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + const mockDataSource = { + query: jest + .fn() + .mockResolvedValue([{ id: 'api-key-id', revokedAt: null }]), + }; + + jest + .spyOn(typeORMService, 'connectToDataSource') + .mockResolvedValue(mockDataSource as any); + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + {} as any, + ); + + const result = await strategy.validate(payload as JwtPayload); + + expect(result).toBeTruthy(); + expect(result.apiKey?.id).toBe('api-key-id'); + }); + + // second we test the ACCESS cases + + it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + userRepository = { + findOne: jest.fn(async () => null), + }; + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + userRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException('User not found', AuthExceptionCode.INVALID_INPUT), + ); + }); + + it('should be truthy if type is ACCESS, no jti, and user exist', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + userRepository = { + findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), + }; + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + userRepository, + ); + + const user = await strategy.validate(payload as JwtPayload); + + expect(user.user?.lastName).toBe('lastNameDefault'); + }); +}); 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 f301acd3c6..0ed9fa965d 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 @@ -10,7 +10,10 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { + AuthContext, + JwtPayload, +} 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'; @@ -18,13 +21,6 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; -export type JwtPayload = { - sub: string; - workspaceId: string; - workspaceMemberId?: string; - jti?: string; -}; - @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( @@ -59,13 +55,13 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(payload: JwtPayload): Promise { - const workspace = await this.workspaceRepository.findOneBy({ - id: payload.workspaceId ?? payload.sub, - }); - let user: User | null = null; + private async validateAPIKey(payload: JwtPayload): Promise { let apiKey: ApiKeyWorkspaceEntity | null = null; + const workspace = await this.workspaceRepository.findOneBy({ + id: payload['sub'], + }); + if (!workspace) { throw new AuthException( 'Workspace not found', @@ -73,52 +69,68 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - if (payload.jti) { - // TODO: Check why it's not working - // const apiKeyRepository = - // await this.twentyORMGlobalManager.getRepositoryForWorkspace( - // workspace.id, - // 'apiKey', - // ); - - const dataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( - workspace.id, - ); - - const workspaceDataSource = - await this.typeORMService.connectToDataSource(dataSourceMetadata); - - const res = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."apiKey" WHERE id = $1`, - [payload.jti], + const dataSourceMetadata = + await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + workspace.id, ); - apiKey = res?.[0]; + const workspaceDataSource = + await this.typeORMService.connectToDataSource(dataSourceMetadata); - if (!apiKey || apiKey.revokedAt) { - throw new AuthException( - 'This API Key is revoked', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); - } + const res = await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."apiKey" WHERE id = $1`, + [payload.jti], + ); + + apiKey = res?.[0]; + + if (!apiKey || apiKey.revokedAt) { + throw new AuthException( + 'This API Key is revoked', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); } - if (payload.workspaceId) { - user = await this.userRepository.findOne({ - where: { id: payload.sub }, - }); - if (!user) { - throw new AuthException( - 'User not found', - AuthExceptionCode.INVALID_INPUT, - ); - } + return { apiKey, workspace }; + } + + private async validateAccessToken(payload: JwtPayload): Promise { + let user: User | null = null; + const workspace = await this.workspaceRepository.findOneBy({ + id: payload['workspaceId'], + }); + + if (!workspace) { + throw new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ); } - // We don't check if the user is a member of the workspace yet + user = await this.userRepository.findOne({ + where: { id: payload.sub }, + }); + if (!user) { + throw new AuthException( + 'User not found', + AuthExceptionCode.INVALID_INPUT, + ); + } + + return { user, workspace }; + } + + async validate(payload: JwtPayload): Promise { const workspaceMemberId = payload.workspaceMemberId; - return { user, apiKey, workspace, workspaceMemberId }; + if (!payload.type && !payload.workspaceId) { + return { ...(await this.validateAPIKey(payload)), workspaceMemberId }; + } + + if (payload.type === 'API_KEY') { + return { ...(await this.validateAPIKey(payload)), workspaceMemberId }; + } + + return { ...(await this.validateAccessToken(payload)), workspaceMemberId }; } } 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 index 20baf3742c..1443ee7a6b 100644 --- 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 @@ -12,11 +12,11 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity'; +import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { - JwtAuthStrategy, + AuthContext, JwtPayload, -} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; -import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +} 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'; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 80c223f4e9..5bbcfbca65 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -1,3 +1,4 @@ +import { WorkspaceTokenType } 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 { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; @@ -8,3 +9,11 @@ export type AuthContext = { workspaceMemberId?: string; workspace: Workspace; }; + +export type JwtPayload = { + sub: string; + workspaceId: string; + workspaceMemberId?: string; + jti?: string; + type?: WorkspaceTokenType; +}; 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 3fdd075074..89a075a030 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 @@ -137,6 +137,10 @@ export class EnvironmentVariables { @IsString() APP_SECRET: string; + @IsOptional() + @IsString() + ACCESS_TOKEN_SECRET: string; + @IsDuration() @IsOptional() ACCESS_TOKEN_EXPIRES_IN = '30m'; @@ -394,8 +398,6 @@ export class EnvironmentVariables { }) REDIS_URL: string; - API_TOKEN_EXPIRES_IN = '100y'; - SHORT_TERM_TOKEN_EXPIRES_IN = '5m'; @CastToBoolean() 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 d78ba1b4e0..4ab4cd1a85 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 @@ -11,13 +11,14 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; -type WorkspaceTokenType = +export type WorkspaceTokenType = | 'ACCESS' | 'LOGIN' | 'REFRESH' | 'FILE' | 'POSTGRES_PROXY' - | 'REMOTE_SERVER'; + | 'REMOTE_SERVER' + | 'API_KEY'; @Injectable() export class JwtWrapperService { @@ -58,6 +59,13 @@ export class JwtWrapperService { } try { + if (!type && !payload.workspaceId) { + return this.jwtService.verify(token, { + ...options, + secret: this.generateAppSecretLegacy(type, payload.workspaceId), + }); + } + return this.jwtService.verify(token, { ...options, secret: this.generateAppSecret(type, payload.workspaceId), @@ -93,4 +101,21 @@ export class JwtWrapperService { .update(`${appSecret}${workspaceId}${type}`) .digest('hex'); } + + generateAppSecretLegacy( + type: WorkspaceTokenType, + workspaceId?: string, + ): string { + const accessTokenSecret = this.environmentService.get( + 'ACCESS_TOKEN_SECRET', + ); + + if (!accessTokenSecret) { + throw new Error('ACCESS_TOKEN_SECRET is not set'); + } + + return createHash('sha256') + .update(`${accessTokenSecret}${workspaceId}${type}`) + .digest('hex'); + } } diff --git a/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts b/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts index 0a61e7354e..5ea58e84a5 100644 --- a/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts +++ b/packages/twenty-server/src/engine/strategies/aggregate-by-workspace-context-id.strategy.ts @@ -1,14 +1,14 @@ import { - HostComponentInfo, ContextId, ContextIdFactory, ContextIdStrategy, + HostComponentInfo, } from '@nestjs/core'; -import { jwtDecode } from 'jwt-decode'; import { Request } from 'express'; +import { jwtDecode } from 'jwt-decode'; -import { JwtPayload } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; +import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; const workspaces = new Map(); 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 c36497b258..9c54066252 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 @@ -57,7 +57,6 @@ yarn command:prod cron:calendar:calendar-event-list-fetch ['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'], ['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'], ['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'], - ['API_TOKEN_EXPIRES_IN', '1000y', 'API token expiration time'], ]}> ### Auth