mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 12:02:10 +03:00
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:
parent
24656e777e
commit
4b5d096441
@ -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
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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,14 +69,6 @@ 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,
|
||||
@ -102,9 +90,23 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (payload.workspaceId) {
|
||||
user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
@ -114,11 +116,21 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
// We don't check if the user is a member of the workspace yet
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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()
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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>();
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user