1895 extensibility v1 application tokens 3 (#16504)

- moves applicationRoleId to application entity
- add new `APPLICATION` FieldActorSource and `APPLICATION`
JwtTokenTypeEnum value
- create a new token with applicationId when executing a function
- when applicationId is in token, check for application.defaultRole
permissions
-use twenty-shared types in `twenty-sdk/application`
- create a new import from generate called "Twenty" that you can use
directly without having to set TWENTY_API_KEY AND TWENTY_API_URL (keep
metadata or core parameter only)
- provide to serverless unique one time BEARER TOKEN to run it

Result
<img width="977" height="566" alt="image"
src="https://github.com/user-attachments/assets/e78428a0-5b13-4975-aa13-58ee3b32450c"
/>

<img width="910" height="596" alt="image"
src="https://github.com/user-attachments/assets/6ec72bf5-7655-4093-a45e-ad269595a324"
/>

<img width="741" height="568" alt="image"
src="https://github.com/user-attachments/assets/7683944c-fd79-4417-8fb2-8e4815cc112f"
/>
This commit is contained in:
martmull
2025-12-15 17:44:23 +01:00
committed by GitHub
parent e33f18bfa8
commit e289f3056e
103 changed files with 1427 additions and 512 deletions
@@ -13,6 +13,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
INVALID_INPUT: 'INVALID_INPUT',
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
@@ -63,6 +63,7 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { TwoFactorAuthenticationMethodEntity } from '../two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from '../two-factor-authentication/two-factor-authentication.module';
@@ -87,6 +88,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UserEntity,
AppTokenEntity,
ApiKeyEntity,
ApplicationEntity,
FeatureFlagEntity,
WorkspaceSSOIdentityProviderEntity,
KeyValuePairEntity,
@@ -4,7 +4,10 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -24,6 +27,7 @@ describe('JwtAuthStrategy', () => {
let userWorkspaceRepository: any;
let userRepository: any;
let apiKeyRepository: any;
let applicationRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
@@ -49,6 +53,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(),
};
applicationRepository = {
findOne: jest.fn(),
};
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
@@ -66,7 +74,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
workspaceRepository.findOneBy.mockResolvedValue(null);
@@ -74,6 +82,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -91,7 +100,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -104,6 +113,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -121,7 +131,7 @@ describe('JwtAuthStrategy', () => {
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -137,6 +147,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -154,7 +165,7 @@ describe('JwtAuthStrategy', () => {
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
type: JwtTokenTypeEnum.API_KEY,
};
const mockWorkspace = new WorkspaceEntity();
@@ -170,6 +181,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -198,7 +210,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -210,6 +222,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -234,7 +247,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -248,6 +261,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -272,7 +286,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
};
@@ -290,6 +304,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -303,6 +318,44 @@ describe('JwtAuthStrategy', () => {
});
});
describe('APPLICATION token validation', () => {
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
const validApplicationId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validApplicationId,
type: JwtTokenTypeEnum.APPLICATION,
applicationId: validApplicationId,
workspaceId: validWorkspaceId,
};
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
applicationRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('Application not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.APPLICATION_NOT_FOUND);
}
});
});
describe('Impersonation validation', () => {
it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => {
const validUserId = randomUUID();
@@ -311,7 +364,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -335,6 +388,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -357,7 +411,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -380,6 +434,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -401,7 +456,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -427,6 +482,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -449,7 +505,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -485,6 +541,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -507,7 +564,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -537,6 +594,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -560,7 +618,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -607,6 +665,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -629,7 +688,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -676,6 +735,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -699,7 +759,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -746,6 +806,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -768,7 +829,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -809,6 +870,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -837,7 +899,7 @@ describe('JwtAuthStrategy', () => {
const payload = {
sub: validUserId,
type: 'ACCESS',
type: JwtTokenTypeEnum.ACCESS,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
@@ -874,6 +936,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
applicationRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
@@ -16,9 +16,11 @@ import {
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationTokenJwtPayload,
type AuthContext,
type FileTokenJwtPayload,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -27,12 +29,16 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(UserWorkspaceEntity)
@@ -52,7 +58,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
>(rawJwtToken);
const appSecretBody =
decodedToken.type === 'WORKSPACE_AGNOSTIC'
decodedToken.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
? decodedToken.userId
: decodedToken.workspaceId;
@@ -270,7 +276,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
private async validateWorkspaceAgnosticToken(
payload: WorkspaceAgnosticTokenJwtPayload,
) {
): Promise<AuthContext> {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
@@ -283,6 +289,39 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, authProvider: payload.authProvider };
}
private async validateApplicationToken(
payload: ApplicationTokenJwtPayload,
): Promise<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId,
});
if (!isDefined(workspace)) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const applicationId = payload.sub ?? payload.applicationId;
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application)) {
throw new AuthException(
'Application not found',
AuthExceptionCode.APPLICATION_NOT_FOUND,
);
}
return {
application,
workspace,
};
}
private isLegacyApiKeyPayload(
payload: JwtPayload,
): payload is ApiKeyTokenJwtPayload {
@@ -291,19 +330,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
async validate(payload: JwtPayload): Promise<AuthContext> {
// Support legacy api keys
if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) {
if (
payload.type === JwtTokenTypeEnum.API_KEY ||
this.isLegacyApiKeyPayload(payload)
) {
return await this.validateAPIKey(payload);
}
if (payload.type === 'WORKSPACE_AGNOSTIC') {
if (payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC) {
return await this.validateWorkspaceAgnosticToken(payload);
}
// `!payload.type` is here to support legacy token
if (payload.type === 'ACCESS' || !payload.type) {
if (payload.type === JwtTokenTypeEnum.ACCESS) {
return await this.validateAccessToken(payload);
}
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
return await this.validateApplicationToken(payload);
}
throw new AuthException(
'Invalid token',
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
@@ -265,10 +265,7 @@ describe('AccessTokenService', () => {
const result = await service.validateTokenByRequest(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'ACCESS',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(
mockDecodedToken,
@@ -154,7 +154,7 @@ export class AccessTokenService {
}
async validateToken(token: string): Promise<AuthContext> {
await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS);
await this.jwtWrapperService.verifyJwtToken(token);
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
@@ -0,0 +1,159 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
describe('ApplicationTokenService', () => {
let service: ApplicationTokenService;
let jwtWrapperService: JwtWrapperService;
let workspaceRepository: Repository<WorkspaceEntity>;
let applicationRepository: Repository<ApplicationEntity>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationTokenService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useClass: Repository,
},
{
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
],
}).compile();
service = module.get<ApplicationTokenService>(ApplicationTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
applicationRepository = module.get<Repository<ApplicationEntity>>(
getRepositoryToken(ApplicationEntity),
);
workspaceRepository = module.get<Repository<WorkspaceEntity>>(
getRepositoryToken(WorkspaceEntity),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateApplicationToken', () => {
it('should generate an application token successfully', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
}),
expect.any(Object),
);
});
it('should handle missing userId successfully', async () => {
const workspaceId = 'workspace-id';
const applicationId = 'application-id';
const mockWorkspace = { id: workspaceId };
const mockApplication = { id: applicationId };
const mockToken = 'mock-token';
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
jest
.spyOn(applicationRepository, 'findOne')
.mockResolvedValue(mockApplication as ApplicationEntity);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: applicationId,
applicationId,
workspaceId: workspaceId,
}),
expect.any(Object),
);
});
});
it('should throw an error if application is not found', async () => {
const workspaceId = 'workspace-id';
const mockWorkspace = { id: workspaceId };
jest.spyOn(applicationRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
await expect(
service.generateApplicationToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
}),
).rejects.toThrow(ApplicationException);
});
it('should throw an error if workspace is not found', async () => {
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateApplicationToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
});
@@ -0,0 +1,80 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { addMilliseconds } from 'date-fns';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
ApplicationTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
@Injectable()
export class ApplicationTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async generateApplicationToken({
workspaceId,
applicationId,
expiresInSeconds,
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
expiresInSeconds: number;
}): Promise<AuthToken> {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
assertIsDefinedOrThrow(
application,
new ApplicationException(
'Application not found',
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
),
);
const jwtPayload: ApplicationTokenJwtPayload = {
sub: applicationId,
applicationId,
workspaceId,
type: JwtTokenTypeEnum.APPLICATION,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.APPLICATION,
workspaceId,
),
expiresIn,
}),
expiresAt,
};
}
}
@@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { LoginTokenService } from './login-token.service';
@@ -67,7 +68,7 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(twentyConfigService.get).toHaveBeenCalledWith(
@@ -77,7 +78,7 @@ describe('LoginTokenService', () => {
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Password,
impersonatorUserId: undefined,
},
@@ -112,14 +113,14 @@ describe('LoginTokenService', () => {
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
JwtTokenTypeEnum.LOGIN,
workspaceId,
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: email,
workspaceId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
authProvider: AuthProviderEnum.Impersonation,
impersonatorUserWorkspaceId,
},
@@ -143,10 +144,7 @@ describe('LoginTokenService', () => {
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual({ sub: mockEmail });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
json: true,
});
@@ -52,10 +52,7 @@ export class LoginTokenService {
}
async verifyLoginToken(loginToken: string): Promise<LoginTokenJwtPayload> {
await this.jwtWrapperService.verifyJwtToken(
loginToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(loginToken);
return this.jwtWrapperService.decode(loginToken, {
json: true,
@@ -100,10 +100,7 @@ describe('RefreshTokenService', () => {
const result = await service.verifyRefreshToken(mockToken);
expect(result).toEqual({ user: mockUser, token: mockAppToken });
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'REFRESH',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
});
it('should throw an error if the token is malformed', async () => {
@@ -154,9 +151,9 @@ describe('RefreshTokenService', () => {
{
sub: userId,
workspaceId,
type: 'REFRESH',
type: JwtTokenTypeEnum.REFRESH,
userId: 'user-id',
targetedTokenType: 'ACCESS',
targetedTokenType: JwtTokenTypeEnum.ACCESS,
},
expect.objectContaining({
secret: 'mock-secret',
@@ -190,8 +187,8 @@ describe('RefreshTokenService', () => {
(jwtWrapperService.decode as jest.Mock).mockReturnValue({
sub: userId,
jti: tokenId,
type: 'REFRESH',
targetedTokenType: 'ACCESS',
type: JwtTokenTypeEnum.REFRESH,
targetedTokenType: JwtTokenTypeEnum.ACCESS,
isImpersonating: true,
impersonatorUserWorkspaceId: 'uw-imp',
impersonatedUserWorkspaceId: 'uw-orig',
@@ -36,10 +36,7 @@ export class RefreshTokenService {
async verifyRefreshToken(refreshToken: string) {
const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN');
await this.jwtWrapperService.verifyJwtToken(
refreshToken,
JwtTokenTypeEnum.REFRESH,
);
await this.jwtWrapperService.verifyJwtToken(refreshToken);
const jwtPayload =
this.jwtWrapperService.decode<RefreshTokenJwtPayload>(refreshToken);
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TransientTokenService } from './transient-token.service';
@@ -72,7 +73,7 @@ describe('TransientTokenService', () => {
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
type: 'LOGIN',
type: JwtTokenTypeEnum.LOGIN,
userId,
workspaceId,
workspaceMemberId,
@@ -108,10 +109,7 @@ describe('TransientTokenService', () => {
userId: mockPayload.userId,
workspaceId: mockPayload.workspaceId,
});
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
});
@@ -53,10 +53,7 @@ export class TransientTokenService {
async verifyTransientToken(
transientToken: string,
): Promise<Omit<TransientTokenJwtPayload, 'type' | 'sub'>> {
await this.jwtWrapperService.verifyJwtToken(
transientToken,
JwtTokenTypeEnum.LOGIN,
);
await this.jwtWrapperService.verifyJwtToken(transientToken);
const { type: _type, ...payload } =
this.jwtWrapperService.decode<TransientTokenJwtPayload>(transientToken);
@@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
describe('WorkspaceAgnosticToken', () => {
let service: WorkspaceAgnosticTokenService;
@@ -95,7 +96,7 @@ describe('WorkspaceAgnosticToken', () => {
authProvider: AuthProviderEnum.Password,
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
},
expect.objectContaining({
secret: 'mocked-secret',
@@ -132,7 +133,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
const mockUser = { id: userId };
@@ -177,7 +178,7 @@ describe('WorkspaceAgnosticToken', () => {
const mockPayload = {
sub: userId,
userId: userId,
type: 'WORKSPACE_AGNOSTIC',
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
};
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
@@ -16,6 +16,8 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Module({
imports: [
@@ -26,6 +28,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
WorkspaceEntity,
UserWorkspaceEntity,
ApiKeyEntity,
ApplicationEntity,
]),
TypeORMModule,
DataSourceModule,
@@ -38,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
exports: [
RenewTokenService,
@@ -45,6 +49,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
LoginTokenService,
RefreshTokenService,
WorkspaceAgnosticTokenService,
ApplicationTokenService,
],
})
export class TokenModule {}
@@ -3,12 +3,14 @@ import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export type AuthContext = {
user?: UserEntity | null | undefined;
apiKey?: ApiKeyEntity | null | undefined;
workspaceMemberId?: string;
workspace?: WorkspaceEntity;
application?: ApplicationEntity | null | undefined;
userWorkspaceId?: string;
userWorkspace?: UserWorkspaceEntity;
authProvider?: AuthProviderEnum;
@@ -28,6 +30,7 @@ export enum JwtTokenTypeEnum {
POSTGRES_PROXY = 'POSTGRES_PROXY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION = 'APPLICATION',
}
type CommonPropertiesJwtPayload = {
@@ -83,6 +86,12 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
jti?: string;
};
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION;
workspaceId: string;
applicationId: string;
};
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
@@ -106,6 +115,7 @@ export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & {
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
@@ -52,6 +52,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
});
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new AuthenticationError(exception);
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
@@ -30,6 +30,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
case AuthExceptionCode.UNAUTHENTICATED:
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.WORKSPACE_NOT_FOUND:
case AuthExceptionCode.APPLICATION_NOT_FOUND:
return 401;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.USER_WORKSPACE_NOT_FOUND: