Fix of broken API Auth (#8338)

Fix done this morning with @FelixMalfait  from #8295

---------

Co-authored-by: guillim <guillaume@twenty.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Guillim 2024-11-06 14:45:33 +01:00 committed by GitHub
parent 24656e777e
commit 4b5d096441
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 309 additions and 67 deletions

View File

@ -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

View File

@ -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),

View File

@ -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,

View File

@ -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');
});
});

View File

@ -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<AuthContext> {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.workspaceId ?? payload.sub,
});
let user: User | null = null;
private async validateAPIKey(payload: JwtPayload): Promise<AuthContext> {
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<ApiKeyWorkspaceEntity>(
// 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<AuthContext> {
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<AuthContext> {
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 };
}
}

View File

@ -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';

View File

@ -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;
};

View File

@ -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()

View File

@ -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');
}
}

View File

@ -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<string, ContextId>();

View File

@ -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'],
]}></ArticleTable>
### Auth