[FRONT COMPONENT] Add Front component token generation (#17855)
closes https://github.com/twentyhq/core-team-issues/issues/2180 https://github.com/user-attachments/assets/a898455d-eb1c-4d22-b585-785e98fc38a7
This commit is contained in:
+3
-3
@@ -356,14 +356,14 @@ describe('JwtAuthStrategy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('APPLICATION token validation', () => {
|
||||
it('should throw AuthExceptionCode if type is APPLICATION, and application not found', async () => {
|
||||
describe('APPLICATION_ACCESS token validation', () => {
|
||||
it('should throw AuthExceptionCode if type is APPLICATION_ACCESS, and application not found', async () => {
|
||||
const validApplicationId = randomUUID();
|
||||
const validWorkspaceId = randomUUID();
|
||||
|
||||
const payload = {
|
||||
sub: validApplicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION,
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
applicationId: validApplicationId,
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import {
|
||||
type AccessTokenJwtPayload,
|
||||
type ApiKeyTokenJwtPayload,
|
||||
ApplicationTokenJwtPayload,
|
||||
ApplicationAccessTokenJwtPayload,
|
||||
type AuthContext,
|
||||
type FileTokenJwtPayload,
|
||||
type JwtPayload,
|
||||
@@ -333,7 +333,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApplicationToken(
|
||||
payload: ApplicationTokenJwtPayload,
|
||||
payload: ApplicationAccessTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.workspaceId,
|
||||
@@ -359,6 +359,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Token carries userId/userWorkspaceId but they are unused.
|
||||
// Compute the intersection of user and application permissions instead.
|
||||
return {
|
||||
application,
|
||||
workspace,
|
||||
@@ -388,7 +390,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
return await this.validateAccessToken(payload);
|
||||
}
|
||||
|
||||
if (payload.type === JwtTokenTypeEnum.APPLICATION) {
|
||||
if (payload.type === JwtTokenTypeEnum.APPLICATION_ACCESS) {
|
||||
return await this.validateApplicationToken(payload);
|
||||
}
|
||||
|
||||
|
||||
+139
-11
@@ -3,11 +3,13 @@ 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 { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
|
||||
describe('ApplicationTokenService', () => {
|
||||
@@ -55,8 +57,8 @@ describe('ApplicationTokenService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateApplicationToken', () => {
|
||||
it('should generate an application token successfully', async () => {
|
||||
describe('generateApplicationAccessToken', () => {
|
||||
it('should generate an application access token successfully', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
@@ -71,7 +73,7 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationToken({
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds: 10,
|
||||
@@ -90,9 +92,11 @@ describe('ApplicationTokenService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing userId successfully', async () => {
|
||||
it('should include optional userWorkspaceId and userId in payload', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const applicationId = 'application-id';
|
||||
const userWorkspaceId = 'user-workspace-id';
|
||||
const userId = 'user-id';
|
||||
const mockWorkspace = { id: workspaceId };
|
||||
const mockApplication = { id: applicationId };
|
||||
const mockToken = 'mock-token';
|
||||
@@ -105,9 +109,11 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockApplication as ApplicationEntity);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateApplicationToken({
|
||||
const result = await service.generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds: 10,
|
||||
});
|
||||
|
||||
@@ -119,7 +125,9 @@ describe('ApplicationTokenService', () => {
|
||||
expect.objectContaining({
|
||||
sub: applicationId,
|
||||
applicationId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
@@ -137,7 +145,7 @@ describe('ApplicationTokenService', () => {
|
||||
.mockResolvedValue(mockWorkspace as WorkspaceEntity);
|
||||
|
||||
await expect(
|
||||
service.generateApplicationToken({
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'non-existent-application',
|
||||
workspaceId: 'workspace-id',
|
||||
expiresInSeconds: 10,
|
||||
@@ -149,11 +157,131 @@ describe('ApplicationTokenService', () => {
|
||||
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generateApplicationToken({
|
||||
service.generateApplicationAccessToken({
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'non-existent-workspace',
|
||||
expiresInSeconds: 10,
|
||||
}),
|
||||
).rejects.toThrow(WorkspaceException);
|
||||
});
|
||||
|
||||
describe('validateApplicationRefreshToken', () => {
|
||||
it('should validate and return payload for a valid refresh token', () => {
|
||||
const mockToken = 'valid-refresh-token';
|
||||
const mockPayload = {
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'workspace-id',
|
||||
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = service.validateApplicationRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockPayload);
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(mockToken);
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when token type is not APPLICATION_REFRESH', () => {
|
||||
const mockToken = 'access-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockReturnValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({
|
||||
sub: 'application-id',
|
||||
applicationId: 'application-id',
|
||||
workspaceId: 'workspace-id',
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
});
|
||||
|
||||
expect(() => service.validateApplicationRefreshToken(mockToken)).toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when token verification fails', () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verifyJwtToken').mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
service.validateApplicationRefreshToken(mockToken),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateApplicationTokenPair', () => {
|
||||
it('should generate both access and refresh tokens', 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.generateApplicationTokenPair({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
expect(result.applicationAccessToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(result.applicationRefreshToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewApplicationTokens', () => {
|
||||
it('should generate a new token pair from validated payload', 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.renewApplicationTokens({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
expect(result.applicationAccessToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(result.applicationRefreshToken).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+143
-13
@@ -1,7 +1,7 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
import ms from 'ms';
|
||||
@@ -9,20 +9,29 @@ 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,
|
||||
type ApplicationAccessTokenJwtPayload,
|
||||
type ApplicationRefreshTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
|
||||
import { type 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';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
|
||||
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
|
||||
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTokenService {
|
||||
constructor(
|
||||
@Inject(JwtWrapperService)
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@@ -30,17 +39,113 @@ export class ApplicationTokenService {
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async generateApplicationToken({
|
||||
async generateApplicationAccessToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
expiresInSeconds,
|
||||
}: Omit<ApplicationTokenJwtPayload, 'type' | 'sub'> & {
|
||||
expiresInSeconds: number;
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): Promise<AuthToken> {
|
||||
const expiresIn = `${expiresInSeconds}s`;
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
return this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
async generateApplicationTokenPair({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
applicationAccessToken: AuthToken;
|
||||
applicationRefreshToken: AuthToken;
|
||||
}> {
|
||||
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
|
||||
|
||||
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
|
||||
[
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
|
||||
expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
this.signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { applicationAccessToken, applicationRefreshToken };
|
||||
}
|
||||
|
||||
validateApplicationRefreshToken(
|
||||
refreshToken: string,
|
||||
): ApplicationRefreshTokenJwtPayload {
|
||||
this.jwtWrapperService.verifyJwtToken(refreshToken);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationRefreshTokenJwtPayload>(
|
||||
refreshToken,
|
||||
{ json: true },
|
||||
);
|
||||
|
||||
if (payload.type !== JwtTokenTypeEnum.APPLICATION_REFRESH) {
|
||||
throw new AuthException(
|
||||
'Expected an application refresh token',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async renewApplicationTokens(payload: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
applicationAccessToken: AuthToken;
|
||||
applicationRefreshToken: AuthToken;
|
||||
}> {
|
||||
return this.generateApplicationTokenPair({
|
||||
workspaceId: payload.workspaceId,
|
||||
applicationId: payload.applicationId,
|
||||
userWorkspaceId: payload.userWorkspaceId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
}
|
||||
|
||||
private async validateWorkspaceAndApplication(
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
});
|
||||
@@ -58,18 +163,43 @@ export class ApplicationTokenService {
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const jwtPayload: ApplicationTokenJwtPayload = {
|
||||
private signApplicationToken({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
tokenType,
|
||||
expiresInSeconds,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
tokenType:
|
||||
| JwtTokenTypeEnum.APPLICATION_ACCESS
|
||||
| JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
expiresInSeconds: number;
|
||||
}): AuthToken {
|
||||
const expiresIn = `${expiresInSeconds}s`;
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const jwtPayload:
|
||||
| ApplicationAccessTokenJwtPayload
|
||||
| ApplicationRefreshTokenJwtPayload = {
|
||||
sub: applicationId,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
type: JwtTokenTypeEnum.APPLICATION,
|
||||
type: tokenType,
|
||||
...(userWorkspaceId ? { userWorkspaceId } : {}),
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.APPLICATION,
|
||||
tokenType,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
|
||||
@@ -40,7 +40,8 @@ export enum JwtTokenTypeEnum {
|
||||
POSTGRES_PROXY = 'POSTGRES_PROXY',
|
||||
REMOTE_SERVER = 'REMOTE_SERVER',
|
||||
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
|
||||
APPLICATION = 'APPLICATION',
|
||||
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
|
||||
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
|
||||
}
|
||||
|
||||
type CommonPropertiesJwtPayload = {
|
||||
@@ -102,10 +103,20 @@ export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
export type ApplicationTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION;
|
||||
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.APPLICATION_REFRESH;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
userWorkspaceId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
@@ -127,7 +138,8 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
export type JwtPayload =
|
||||
| AccessTokenJwtPayload
|
||||
| ApiKeyTokenJwtPayload
|
||||
| ApplicationTokenJwtPayload
|
||||
| ApplicationAccessTokenJwtPayload
|
||||
| ApplicationRefreshTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
| LoginTokenJwtPayload
|
||||
| TransientTokenJwtPayload
|
||||
|
||||
Reference in New Issue
Block a user