feat(core): introduce JWT API keys for the public API (#11005)

This commit is contained in:
Ricardo Espinoza 2024-10-18 12:06:44 +02:00 committed by GitHub
parent 6a722c45ea
commit 679fa4a10a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 252 additions and 69 deletions

View file

@ -1,66 +1,88 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { randomString } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
import type { ApiKey } from '@/databases/entities/api-key'; import type { ApiKey } from '@/databases/entities/api-key';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { EventService } from '@/events/event.service';
import type { ApiKeysRequest, AuthenticatedRequest } from '@/requests'; import type { ApiKeysRequest, AuthenticatedRequest } from '@/requests';
import { API_KEY_PREFIX } from '@/services/public-api-key.service'; import { PublicApiKeyService } from '@/services/public-api-key.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { ApiKeysController } from '../api-keys.controller'; import { ApiKeysController } from '../api-keys.controller';
describe('ApiKeysController', () => { describe('ApiKeysController', () => {
const apiKeysRepository = mockInstance(ApiKeyRepository); const publicApiKeyService = mockInstance(PublicApiKeyService);
const eventService = mockInstance(EventService);
const controller = Container.get(ApiKeysController); const controller = Container.get(ApiKeysController);
let req: AuthenticatedRequest; let req: AuthenticatedRequest;
beforeAll(() => { beforeAll(() => {
req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) }); req = { user: { id: '123' } } as AuthenticatedRequest;
}); });
describe('createAPIKey', () => { describe('createAPIKey', () => {
it('should create and save an API key', async () => { it('should create and save an API key', async () => {
// Arrange
const apiKeyData = { const apiKeyData = {
id: '123', id: '123',
userId: '123', userId: '123',
label: 'My API Key', label: 'My API Key',
apiKey: `${API_KEY_PREFIX}${randomString(42)}`, apiKey: 'apiKey********',
createdAt: new Date(), createdAt: new Date(),
} as ApiKey; } as ApiKey;
apiKeysRepository.upsert.mockImplementation(); const req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
apiKeysRepository.findOneByOrFail.mockResolvedValue(apiKeyData); publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData);
// Act
const newApiKey = await controller.createAPIKey(req); const newApiKey = await controller.createAPIKey(req);
expect(apiKeysRepository.upsert).toHaveBeenCalled(); // Assert
expect(publicApiKeyService.createPublicApiKeyForUser).toHaveBeenCalled();
expect(apiKeyData).toEqual(newApiKey); expect(apiKeyData).toEqual(newApiKey);
expect(eventService.emit).toHaveBeenCalledWith(
'public-api-key-created',
expect.objectContaining({ user: req.user, publicApi: false }),
);
}); });
}); });
describe('getAPIKeys', () => { describe('getAPIKeys', () => {
it('should return the users api keys redacted', async () => { it('should return the users api keys redacted', async () => {
// Arrange
const apiKeyData = { const apiKeyData = {
id: '123', id: '123',
userId: '123', userId: '123',
label: 'My API Key', label: 'My API Key',
apiKey: `${API_KEY_PREFIX}${randomString(42)}`, apiKey: 'apiKey***',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(),
} as ApiKey; } as ApiKey;
apiKeysRepository.findBy.mockResolvedValue([apiKeyData]); publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([apiKeyData]);
// Act
const apiKeys = await controller.getAPIKeys(req); const apiKeys = await controller.getAPIKeys(req);
expect(apiKeys[0].apiKey).not.toEqual(apiKeyData.apiKey);
expect(apiKeysRepository.findBy).toHaveBeenCalledWith({ userId: req.user.id }); // Assert
expect(apiKeys).toEqual([apiKeyData]);
expect(publicApiKeyService.getRedactedApiKeysForUser).toHaveBeenCalledWith(
expect.objectContaining({ id: req.user.id }),
);
}); });
}); });
describe('deleteAPIKey', () => { describe('deleteAPIKey', () => {
it('should delete the API key', async () => { it('should delete the API key', async () => {
// Arrange
const user = mock<User>({ const user = mock<User>({
id: '123', id: '123',
password: 'password', password: 'password',
@ -68,12 +90,22 @@ describe('ApiKeysController', () => {
role: 'global:member', role: 'global:member',
mfaEnabled: false, mfaEnabled: false,
}); });
const req = mock<ApiKeysRequest.DeleteAPIKey>({ user, params: { id: user.id } }); const req = mock<ApiKeysRequest.DeleteAPIKey>({ user, params: { id: user.id } });
// Act
await controller.deleteAPIKey(req); await controller.deleteAPIKey(req);
expect(apiKeysRepository.delete).toHaveBeenCalledWith({
userId: req.user.id, publicApiKeyService.deleteApiKeyForUser.mockResolvedValue();
id: req.params.id,
}); // Assert
expect(publicApiKeyService.deleteApiKeyForUser).toHaveBeenCalledWith(user, user.id);
expect(eventService.emit).toHaveBeenCalledWith(
'public-api-key-deleted',
expect.objectContaining({ user, publicApi: false }),
);
}); });
}); });
}); });

View file

@ -3,16 +3,13 @@ import type { Router } from 'express';
import express from 'express'; import express from 'express';
import type { HttpError } from 'express-openapi-validator/dist/framework/types'; import type { HttpError } from 'express-openapi-validator/dist/framework/types';
import fs from 'fs/promises'; import fs from 'fs/promises';
import type { OpenAPIV3 } from 'openapi-types';
import path from 'path'; import path from 'path';
import type { JsonObject } from 'swagger-ui-express'; import type { JsonObject } from 'swagger-ui-express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import validator from 'validator'; import validator from 'validator';
import YAML from 'yamljs'; import YAML from 'yamljs';
import { EventService } from '@/events/event.service';
import { License } from '@/license'; import { License } from '@/license';
import type { AuthenticatedRequest } from '@/requests';
import { PublicApiKeyService } from '@/services/public-api-key.service'; import { PublicApiKeyService } from '@/services/public-api-key.service';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
@ -85,28 +82,7 @@ async function createApiRouter(
}, },
validateSecurity: { validateSecurity: {
handlers: { handlers: {
ApiKeyAuth: async ( ApiKeyAuth: Container.get(PublicApiKeyService).getAuthMiddleware(version),
req: AuthenticatedRequest,
_scopes: unknown,
schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => {
const providedApiKey = req.headers[schema.name.toLowerCase()] as string;
const user = await Container.get(PublicApiKeyService).getUserForApiKey(providedApiKey);
if (!user) return false;
Container.get(EventService).emit('public-api-invoked', {
userId: user.id,
path: req.path,
method: req.method,
apiVersion: version,
});
req.user = user;
return true;
},
}, },
}, },
}), }),

View file

@ -0,0 +1,147 @@
import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import type { OpenAPIV3 } from 'openapi-types';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { getConnection } from '@/db';
import type { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
import { createOwnerWithApiKey } from '@test-integration/db/users';
import * as testDb from '@test-integration/test-db';
import { JwtService } from '../jwt.service';
import { PublicApiKeyService } from '../public-api-key.service';
const mockReqWith = (apiKey: string, path: string, method: string) => {
return mock<AuthenticatedRequest>({
path,
method,
headers: {
'x-n8n-api-key': apiKey,
},
});
};
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
const eventService = mock<EventService>();
const securitySchema = mock<OpenAPIV3.ApiKeySecurityScheme>({
name: 'X-N8N-API-KEY',
});
const jwtService = new JwtService(instanceSettings);
let userRepository: UserRepository;
let apiKeyRepository: ApiKeyRepository;
describe('PublicApiKeyService', () => {
beforeEach(async () => {
await testDb.truncate(['User']);
jest.clearAllMocks();
});
beforeAll(async () => {
await testDb.init();
userRepository = new UserRepository(getConnection());
apiKeyRepository = new ApiKeyRepository(getConnection());
});
afterAll(async () => {
await testDb.terminate();
});
describe('getAuthMiddleware', () => {
it('should return false if api key is invalid', async () => {
//Arrange
const apiKey = 'invalid';
const path = '/test';
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
//Act
const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema);
//Assert
expect(response).toBe(false);
});
it('should return false if valid api key is not in database', async () => {
//Arrange
const apiKey = jwtService.sign({ sub: '123' });
const path = '/test';
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
//Act
const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema);
//Assert
expect(response).toBe(false);
});
it('should return true if valid api key exist in the database', async () => {
//Arrange
const path = '/test';
const method = 'GET';
const apiVersion = 'v1';
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const owner = await createOwnerWithApiKey();
const [{ apiKey }] = owner.apiKeys;
const middleware = publicApiKeyService.getAuthMiddleware(apiVersion);
//Act
const response = await middleware(mockReqWith(apiKey, path, method), {}, securitySchema);
//Assert
expect(response).toBe(true);
expect(eventService.emit).toHaveBeenCalledTimes(1);
expect(eventService.emit).toHaveBeenCalledWith(
'public-api-invoked',
expect.objectContaining({
userId: owner.id,
path,
method,
apiVersion: 'v1',
}),
);
});
});
});

View file

@ -1,16 +1,28 @@
import { randomBytes } from 'node:crypto'; import type { OpenAPIV3 } from 'openapi-types';
import Container, { Service } from 'typedi'; import { Service } from 'typedi';
import { ApiKey } from '@/databases/entities/api-key'; import { ApiKey } from '@/databases/entities/api-key';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
export const API_KEY_PREFIX = 'n8n_api_'; import { JwtService } from './jwt.service';
const API_KEY_AUDIENCE = 'public-api';
const API_KEY_ISSUER = 'n8n';
const REDACT_API_KEY_REVEAL_COUNT = 15;
const REDACT_API_KEY_MAX_LENGTH = 80;
@Service() @Service()
export class PublicApiKeyService { export class PublicApiKeyService {
constructor(private readonly apiKeyRepository: ApiKeyRepository) {} constructor(
private readonly apiKeyRepository: ApiKeyRepository,
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
private readonly eventService: EventService,
) {}
/** /**
* Creates a new public API key for the specified user. * Creates a new public API key for the specified user.
@ -18,7 +30,7 @@ export class PublicApiKeyService {
* @returns A promise that resolves to the newly created API key. * @returns A promise that resolves to the newly created API key.
*/ */
async createPublicApiKeyForUser(user: User) { async createPublicApiKeyForUser(user: User) {
const apiKey = this.createApiKeyString(); const apiKey = this.generateApiKey(user);
await this.apiKeyRepository.upsert( await this.apiKeyRepository.upsert(
this.apiKeyRepository.create({ this.apiKeyRepository.create({
userId: user.id, userId: user.id,
@ -48,8 +60,8 @@ export class PublicApiKeyService {
await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId });
} }
async getUserForApiKey(apiKey: string) { private async getUserForApiKey(apiKey: string) {
return await Container.get(UserRepository) return await this.userRepository
.createQueryBuilder('user') .createQueryBuilder('user')
.innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id') .innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id')
.where('apiKey.apiKey = :apiKey', { apiKey }) .where('apiKey.apiKey = :apiKey', { apiKey })
@ -68,13 +80,39 @@ export class PublicApiKeyService {
* ``` * ```
*/ */
redactApiKey(apiKey: string) { redactApiKey(apiKey: string) {
const keepLength = 5; const visiblePart = apiKey.slice(0, REDACT_API_KEY_REVEAL_COUNT);
return ( const redactedPart = '*'.repeat(apiKey.length - REDACT_API_KEY_REVEAL_COUNT);
API_KEY_PREFIX +
apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) + const completeRedactedApiKey = visiblePart + redactedPart;
'*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength)
); return completeRedactedApiKey.slice(0, REDACT_API_KEY_MAX_LENGTH);
} }
createApiKeyString = () => `${API_KEY_PREFIX}${randomBytes(40).toString('hex')}`; getAuthMiddleware(version: string) {
return async (
req: AuthenticatedRequest,
_scopes: unknown,
schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => {
const providedApiKey = req.headers[schema.name.toLowerCase()] as string;
const user = await this.getUserForApiKey(providedApiKey);
if (!user) return false;
this.eventService.emit('public-api-invoked', {
userId: user.id,
path: req.path,
method: req.method,
apiVersion: version,
});
req.user = user;
return true;
};
}
private generateApiKey = (user: User) =>
this.jwtService.sign({ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE });
} }

View file

@ -1,17 +1,16 @@
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { randomString } from 'n8n-workflow';
import Container from 'typedi'; import Container from 'typedi';
import { AuthIdentity } from '@/databases/entities/auth-identity'; import { AuthIdentity } from '@/databases/entities/auth-identity';
import { type GlobalRole, type User } from '@/databases/entities/user'; import { type GlobalRole, type User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository'; import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository'; import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { MfaService } from '@/mfa/mfa.service'; import { MfaService } from '@/mfa/mfa.service';
import { TOTPService } from '@/mfa/totp.service'; import { TOTPService } from '@/mfa/totp.service';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { randomApiKey, randomEmail, randomName, randomValidPassword } from '../random'; import { randomEmail, randomName, randomValidPassword } from '../random';
// pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)` // pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)`
const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK'; const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK';
@ -81,17 +80,8 @@ export async function createUserWithMfaEnabled(
}; };
} }
const createApiKeyEntity = (user: User) => {
const apiKey = randomApiKey();
return Container.get(ApiKeyRepository).create({
userId: user.id,
label: randomString(10),
apiKey,
});
};
export const addApiKey = async (user: User) => { export const addApiKey = async (user: User) => {
return await Container.get(ApiKeyRepository).save(createApiKeyEntity(user)); return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user);
}; };
export async function createOwnerWithApiKey() { export async function createOwnerWithApiKey() {