diff --git a/packages/@n8n/api-types/src/api-keys.ts b/packages/@n8n/api-types/src/api-keys.ts index e812786e78..a805d898f2 100644 --- a/packages/@n8n/api-types/src/api-keys.ts +++ b/packages/@n8n/api-types/src/api-keys.ts @@ -1,9 +1,14 @@ +/** Unix timestamp. Seconds since epoch */ +export type UnixTimestamp = number | null; + export type ApiKey = { id: string; label: string; apiKey: string; createdAt: string; updatedAt: string; + /** Null if API key never expires */ + expiresAt: UnixTimestamp | null; }; export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string }; diff --git a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts new file mode 100644 index 0000000000..923e0462bb --- /dev/null +++ b/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-api-key-request.dto.test.ts @@ -0,0 +1,53 @@ +import { CreateApiKeyRequestDto } from '../create-api-key-request.dto'; + +describe('CreateApiKeyRequestDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'expiresAt in the future', + expiresAt: Date.now() / 1000 + 1000, + }, + { + name: 'expiresAt null', + expiresAt: null, + }, + ])('should succeed validation for $name', ({ expiresAt }) => { + const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt }); + + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'expiresAt in the past', + expiresAt: Date.now() / 1000 - 1000, + expectedErrorPath: ['expiresAt'], + }, + { + name: 'expiresAt with string', + expiresAt: 'invalid', + expectedErrorPath: ['expiresAt'], + }, + { + name: 'expiresAt with []', + expiresAt: [], + expectedErrorPath: ['expiresAt'], + }, + { + name: 'expiresAt with {}', + expiresAt: {}, + expectedErrorPath: ['expiresAt'], + }, + ])('should fail validation for $name', ({ expiresAt, expectedErrorPath }) => { + const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt }); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts b/packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts similarity index 75% rename from packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts rename to packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts index beb7ebcf0d..10d6b0c31f 100644 --- a/packages/@n8n/api-types/src/dto/api-keys/__tests__/create-or-update.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/api-keys/__tests__/update-api-key-request.dto.test.ts @@ -1,9 +1,9 @@ -import { CreateOrUpdateApiKeyRequestDto } from '../create-or-update-api-key-request.dto'; +import { UpdateApiKeyRequestDto } from '../update-api-key-request.dto'; -describe('CreateOrUpdateApiKeyRequestDto', () => { +describe('UpdateApiKeyRequestDto', () => { describe('Valid requests', () => { test('should allow valid label', () => { - const result = CreateOrUpdateApiKeyRequestDto.safeParse({ + const result = UpdateApiKeyRequestDto.safeParse({ label: 'valid label', }); expect(result.success).toBe(true); @@ -28,7 +28,7 @@ describe('CreateOrUpdateApiKeyRequestDto', () => { expectedErrorPath: ['label'], }, ])('should fail validation for $name', ({ label, expectedErrorPath }) => { - const result = CreateOrUpdateApiKeyRequestDto.safeParse({ label }); + const result = UpdateApiKeyRequestDto.safeParse({ label }); expect(result.success).toBe(false); diff --git a/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts b/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts new file mode 100644 index 0000000000..f5e66b0d62 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/api-keys/create-api-key-request.dto.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { UpdateApiKeyRequestDto } from './update-api-key-request.dto'; + +const isTimeNullOrInFuture = (value: number | null) => { + if (!value) return true; + return value > Date.now() / 1000; +}; + +export class CreateApiKeyRequestDto extends UpdateApiKeyRequestDto.extend({ + expiresAt: z + .number() + .nullable() + .refine(isTimeNullOrInFuture, { message: 'Expiration date must be in the future or null' }), +}) {} diff --git a/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts b/packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts similarity index 78% rename from packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts rename to packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts index 168c28c2fa..9cb1b73fa5 100644 --- a/packages/@n8n/api-types/src/dto/api-keys/create-or-update-api-key-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/api-keys/update-api-key-request.dto.ts @@ -8,6 +8,6 @@ const xssCheck = (value: string) => whiteList: {}, }); -export class CreateOrUpdateApiKeyRequestDto extends Z.class({ +export class UpdateApiKeyRequestDto extends Z.class({ label: z.string().max(50).min(1).refine(xssCheck), }) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 3c6db394c2..ad695f0bb2 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -49,4 +49,5 @@ export { ManualRunQueryDto } from './workflows/manual-run-query.dto'; export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; -export { CreateOrUpdateApiKeyRequestDto } from './api-keys/create-or-update-api-key-request.dto'; +export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto'; +export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto'; diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index aaa530a39b..eb13081b48 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -79,7 +79,9 @@ describe('ApiKeysController', () => { updatedAt: new Date(), } as ApiKey; - publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([apiKeyData]); + publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([ + { ...apiKeyData, expiresAt: null }, + ]); // Act @@ -87,7 +89,7 @@ describe('ApiKeysController', () => { // Assert - expect(apiKeys).toEqual([apiKeyData]); + expect(apiKeys).toEqual([{ ...apiKeyData, expiresAt: null }]); expect(publicApiKeyService.getRedactedApiKeysForUser).toHaveBeenCalledWith( expect.objectContaining({ id: req.user.id }), ); diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 17ed524b82..e2a824068a 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -1,4 +1,4 @@ -import { CreateOrUpdateApiKeyRequestDto } from '@n8n/api-types'; +import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; import type { RequestHandler } from 'express'; import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; @@ -34,7 +34,7 @@ export class ApiKeysController { async createAPIKey( req: AuthenticatedRequest, _res: Response, - @Body payload: CreateOrUpdateApiKeyRequestDto, + @Body { label, expiresAt }: CreateApiKeyRequestDto, ) { const currentNumberOfApiKeys = await this.apiKeysRepository.countBy({ userId: req.user.id }); @@ -43,7 +43,8 @@ export class ApiKeysController { } const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, { - label: payload.label, + label, + expiresAt, }); this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false }); @@ -52,6 +53,7 @@ export class ApiKeysController { ...newApiKey, apiKey: this.publicApiKeyService.redactApiKey(newApiKey.apiKey), rawApiKey: newApiKey.apiKey, + expiresAt, }; } @@ -84,10 +86,10 @@ export class ApiKeysController { req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string, - @Body payload: CreateOrUpdateApiKeyRequestDto, + @Body { label }: UpdateApiKeyRequestDto, ) { await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, { - label: payload.label, + label, }); return { success: true }; diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index f2e43c3181..719f922fb2 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -1,4 +1,7 @@ +import type { UnixTimestamp, UpdateApiKeyRequestDto } from '@n8n/api-types'; +import type { CreateApiKeyRequestDto } from '@n8n/api-types/src/dto/api-keys/create-api-key-request.dto'; import { Service } from '@n8n/di'; +import { TokenExpiredError } from 'jsonwebtoken'; import type { OpenAPIV3 } from 'openapi-types'; import { ApiKey } from '@/databases/entities/api-key'; @@ -28,15 +31,14 @@ export class PublicApiKeyService { * Creates a new public API key for the specified user. * @param user - The user for whom the API key is being created. */ - async createPublicApiKeyForUser(user: User, { label }: { label: string }) { - const apiKey = this.generateApiKey(user); - await this.apiKeyRepository.upsert( + async createPublicApiKeyForUser(user: User, { label, expiresAt }: CreateApiKeyRequestDto) { + const apiKey = this.generateApiKey(user, expiresAt); + await this.apiKeyRepository.insert( this.apiKeyRepository.create({ userId: user.id, apiKey, label, }), - ['apiKey'], ); return await this.apiKeyRepository.findOneByOrFail({ apiKey }); @@ -45,13 +47,13 @@ export class PublicApiKeyService { /** * Retrieves and redacts API keys for a given user. * @param user - The user for whom to retrieve and redact API keys. - * @returns A promise that resolves to an array of objects containing redacted API keys. */ async getRedactedApiKeysForUser(user: User) { const apiKeys = await this.apiKeyRepository.findBy({ userId: user.id }); return apiKeys.map((apiKeyRecord) => ({ ...apiKeyRecord, apiKey: this.redactApiKey(apiKeyRecord.apiKey), + expiresAt: this.getApiKeyExpiration(apiKeyRecord.apiKey), })); } @@ -59,7 +61,7 @@ export class PublicApiKeyService { await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); } - async updateApiKeyForUser(user: User, apiKeyId: string, { label }: { label?: string } = {}) { + async updateApiKeyForUser(user: User, apiKeyId: string, { label }: UpdateApiKeyRequestDto) { await this.apiKeyRepository.update({ id: apiKeyId, userId: user.id }, { label }); } @@ -105,6 +107,16 @@ export class PublicApiKeyService { if (!user) return false; + try { + this.jwtService.verify(providedApiKey, { + issuer: API_KEY_ISSUER, + audience: API_KEY_AUDIENCE, + }); + } catch (e) { + if (e instanceof TokenExpiredError) return false; + throw e; + } + this.eventService.emit('public-api-invoked', { userId: user.id, path: req.path, @@ -118,6 +130,17 @@ export class PublicApiKeyService { }; } - private generateApiKey = (user: User) => - this.jwtService.sign({ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE }); + private generateApiKey = (user: User, expiresAt: UnixTimestamp) => { + const nowInSeconds = Math.floor(Date.now() / 1000); + + return this.jwtService.sign( + { sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE }, + { ...(expiresAt && { expiresIn: expiresAt - nowInSeconds }) }, + ); + }; + + private getApiKeyExpiration = (apiKey: string) => { + const decoded = this.jwtService.decode(apiKey); + return decoded?.exp ?? null; + }; } diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index e1649d4b0b..0f0dbf4e9e 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -4,6 +4,7 @@ import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { ApiKeyRepository } from '@/databases/repositories/api-key.repository'; +import { License } from '@/license'; import { PublicApiKeyService } from '@/services/public-api-key.service'; import { mockInstance } from '@test/mocking'; @@ -13,6 +14,10 @@ import * as testDb from './shared/test-db'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; +const license = mockInstance(License); + +license.getApiKeysPerUserLimit.mockImplementation(() => 2); + const testServer = utils.setupTestServer({ endpointGroups: ['apiKeys'] }); let publicApiKeyService: PublicApiKeyService; @@ -56,11 +61,11 @@ describe('Owner shell', () => { ownerShell = await createUserShell('global:owner'); }); - test('POST /api-keys should create an api key', async () => { + test('POST /api-keys should create an api key with no expiration', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; @@ -79,6 +84,39 @@ describe('Owner shell', () => { createdAt: expect.any(Date), updatedAt: expect.any(Date), }); + + expect(newApiKey.expiresAt).toBeNull(); + expect(newApiKey.rawApiKey).toBeDefined(); + }); + + test('POST /api-keys should create an api key with expiration', async () => { + const expiresAt = Date.now() + 1000; + + const newApiKeyResponse = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key', expiresAt }); + + const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; + + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKey).toBeDefined(); + + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: ownerShell.id, + }); + + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: ownerShell.id, + apiKey: newApiKey.rawApiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + expect(newApiKey.expiresAt).toBe(expiresAt); + expect(newApiKey.rawApiKey).toBeDefined(); }); test('POST /api-keys should fail if max number of API keys reached', async () => { @@ -93,24 +131,40 @@ describe('Owner shell', () => { }); test('GET /api-keys should fetch the api key redacted', async () => { - const newApiKeyResponse = await testServer + const expirationDateInTheFuture = Date.now() + 1000; + + const apiKeyWithNoExpiration = await testServer .authAgentFor(ownerShell) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); + + const apiKeyWithExpiration = await testServer + .authAgentFor(ownerShell) + .post('/api-keys') + .send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); - const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey); - - expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ - id: newApiKeyResponse.body.data.id, - label: 'My API Key', + expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({ + id: apiKeyWithExpiration.body.data.id, + label: 'My API Key 2', userId: ownerShell.id, - apiKey: redactedApiKey, + apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), + expiresAt: expirationDateInTheFuture, + }); + + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: apiKeyWithNoExpiration.body.data.id, + label: 'My API Key', + userId: ownerShell.id, + apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + expiresAt: null, }); }); @@ -118,7 +172,7 @@ describe('Owner shell', () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); const deleteApiKeyResponse = await testServer .authAgentFor(ownerShell) @@ -143,11 +197,11 @@ describe('Member', () => { await utils.setInstanceOwnerSetUp(true); }); - test('POST /api-keys should create an api key', async () => { + test('POST /api-keys should create an api key with no expiration', async () => { const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); @@ -165,6 +219,39 @@ describe('Member', () => { createdAt: expect.any(Date), updatedAt: expect.any(Date), }); + + expect(newApiKeyResponse.body.data.expiresAt).toBeNull(); + expect(newApiKeyResponse.body.data.rawApiKey).toBeDefined(); + }); + + test('POST /api-keys should create an api key with expiration', async () => { + const expiresAt = Date.now() + 1000; + + const newApiKeyResponse = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key', expiresAt }); + + const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; + + expect(newApiKeyResponse.statusCode).toBe(200); + expect(newApiKey).toBeDefined(); + + const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ + userId: member.id, + }); + + expect(newStoredApiKey).toEqual({ + id: expect.any(String), + label: 'My API Key', + userId: member.id, + apiKey: newApiKey.rawApiKey, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + expect(newApiKey.expiresAt).toBe(expiresAt); + expect(newApiKey.rawApiKey).toBeDefined(); }); test('POST /api-keys should fail if max number of API keys reached', async () => { @@ -179,36 +266,48 @@ describe('Member', () => { }); test('GET /api-keys should fetch the api key redacted', async () => { - const newApiKeyResponse = await testServer + const expirationDateInTheFuture = Date.now() + 1000; + + const apiKeyWithNoExpiration = await testServer .authAgentFor(member) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); + + const apiKeyWithExpiration = await testServer + .authAgentFor(member) + .post('/api-keys') + .send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); - const redactedApiKey = publicApiKeyService.redactApiKey(newApiKeyResponse.body.data.rawApiKey); - - expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ - id: newApiKeyResponse.body.data.id, - label: 'My API Key', + expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({ + id: apiKeyWithExpiration.body.data.id, + label: 'My API Key 2', userId: member.id, - apiKey: redactedApiKey, + apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), + expiresAt: expirationDateInTheFuture, }); - expect(newApiKeyResponse.body.data.rawApiKey).not.toEqual( - retrieveAllApiKeysResponse.body.data[0].apiKey, - ); + expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + id: apiKeyWithNoExpiration.body.data.id, + label: 'My API Key', + userId: member.id, + apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey), + createdAt: expect.any(String), + updatedAt: expect.any(String), + expiresAt: null, + }); }); test('DELETE /api-keys/:id should delete the api key', async () => { const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') - .send({ label: 'My API Key' }); + .send({ label: 'My API Key', expiresAt: null }); const deleteApiKeyResponse = await testServer .authAgentFor(member) diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 88751fd727..af0cf99820 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -83,6 +83,7 @@ export async function createUserWithMfaEnabled( export const addApiKey = async (user: User) => { return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, { label: randomName(), + expiresAt: null, }); }; diff --git a/packages/editor-ui/src/api/api-keys.ts b/packages/editor-ui/src/api/api-keys.ts index 5ea2f593b7..c9af96f136 100644 --- a/packages/editor-ui/src/api/api-keys.ts +++ b/packages/editor-ui/src/api/api-keys.ts @@ -1,6 +1,11 @@ import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; -import type { CreateOrUpdateApiKeyRequestDto, ApiKey, ApiKeyWithRawValue } from '@n8n/api-types'; +import type { + CreateApiKeyRequestDto, + UpdateApiKeyRequestDto, + ApiKey, + ApiKeyWithRawValue, +} from '@n8n/api-types'; export async function getApiKeys(context: IRestApiContext): Promise { return await makeRestApiRequest(context, 'GET', '/api-keys'); @@ -8,7 +13,7 @@ export async function getApiKeys(context: IRestApiContext): Promise { export async function createApiKey( context: IRestApiContext, - payload: CreateOrUpdateApiKeyRequestDto, + payload: CreateApiKeyRequestDto, ): Promise { return await makeRestApiRequest(context, 'POST', '/api-keys', payload); } @@ -23,7 +28,7 @@ export async function deleteApiKey( export async function updateApiKey( context: IRestApiContext, id: string, - payload: CreateOrUpdateApiKeyRequestDto, + payload: UpdateApiKeyRequestDto, ): Promise<{ success: boolean }> { return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload); } diff --git a/packages/editor-ui/src/components/ApiKeyCard.vue b/packages/editor-ui/src/components/ApiKeyCard.vue index 2167bede96..0c3fe82975 100644 --- a/packages/editor-ui/src/components/ApiKeyCard.vue +++ b/packages/editor-ui/src/components/ApiKeyCard.vue @@ -40,9 +40,19 @@ async function onAction(action: string) { } } -const getApiCreationTime = (apiKey: ApiKey): string => { - const timeAgo = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toRelative() ?? ''; - return i18n.baseText('settings.api.creationTime', { interpolate: { time: timeAgo } }); +const hasApiKeyExpired = (apiKey: ApiKey) => { + if (!apiKey.expiresAt) return false; + return apiKey.expiresAt <= Date.now() / 1000; +}; + +const getExpirationTime = (apiKey: ApiKey): string => { + if (!apiKey.expiresAt) return i18n.baseText('settings.api.neverExpires'); + + if (hasApiKeyExpired(apiKey)) return i18n.baseText('settings.api.expired'); + + const time = DateTime.fromSeconds(apiKey.expiresAt).toFormat('ccc, MMM d yyyy'); + + return i18n.baseText('settings.api.expirationTime', { interpolate: { time } }); }; @@ -53,9 +63,9 @@ const getApiCreationTime = (apiKey: ApiKey): string => { {{ apiKey.label }} -
- - {{ getApiCreationTime(apiKey) }} +
+ + {{ getExpirationTime(apiKey) }}
diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts index 781a8341bd..3aac4e2434 100644 --- a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts +++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.test.ts @@ -4,7 +4,10 @@ import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, STORES } from '@/constants'; import { cleanupAppModals, createAppModals, mockedStore, retry } from '@/__tests__/utils'; import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue'; import { fireEvent } from '@testing-library/vue'; + import { useApiKeysStore } from '@/stores/apiKeys.store'; +import { DateTime } from 'luxon'; +import type { ApiKeyWithRawValue } from '@n8n/api-types'; const renderComponent = createComponentRenderer(ApiKeyEditModal, { pinia: createTestingPinia({ @@ -18,6 +21,16 @@ const renderComponent = createComponentRenderer(ApiKeyEditModal, { }), }); +const testApiKey: ApiKeyWithRawValue = { + id: '123', + label: 'new api key', + apiKey: '123456***', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + rawApiKey: '123456', + expiresAt: 0, +}; + const apiKeysStore = mockedStore(useApiKeysStore); describe('ApiKeyCreateOrEditModal', () => { @@ -30,15 +43,8 @@ describe('ApiKeyCreateOrEditModal', () => { vi.clearAllMocks(); }); - test('should allow creating API key from modal', async () => { - apiKeysStore.createApiKey.mockResolvedValue({ - id: '123', - label: 'new api key', - apiKey: '123456', - createdAt: new Date().toString(), - updatedAt: new Date().toString(), - rawApiKey: '***456', - }); + test('should allow creating API key with default expiration (30 days)', async () => { + apiKeysStore.createApiKey.mockResolvedValue(testApiKey); const { getByText, getByPlaceholderText } = renderComponent({ props: { @@ -59,6 +65,69 @@ describe('ApiKeyCreateOrEditModal', () => { await fireEvent.click(saveButton); + expect(getByText('API Key Created')).toBeInTheDocument(); + + expect(getByText('Done')).toBeInTheDocument(); + + expect( + getByText('Make sure to copy your API key now as you will not be able to see this again.'), + ).toBeInTheDocument(); + + expect(getByText('You can find more details in')).toBeInTheDocument(); + + expect(getByText('the API documentation')).toBeInTheDocument(); + + expect(getByText('Click to copy')).toBeInTheDocument(); + + expect(getByText('new api key')).toBeInTheDocument(); + }); + + test('should allow creating API key with custom expiration', async () => { + apiKeysStore.createApiKey.mockResolvedValue({ + id: '123', + label: 'new api key', + apiKey: '123456', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + rawApiKey: '***456', + expiresAt: 0, + }); + + const { getByText, getByPlaceholderText, getByTestId } = renderComponent({ + props: { + mode: 'new', + }, + }); + + await retry(() => expect(getByText('Create API Key')).toBeInTheDocument()); + expect(getByText('Label')).toBeInTheDocument(); + + const inputLabel = getByPlaceholderText('e.g Internal Project'); + const saveButton = getByText('Save'); + const expirationSelect = getByTestId('expiration-select'); + + expect(inputLabel).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + expect(expirationSelect).toBeInTheDocument(); + + await fireEvent.update(inputLabel, 'new label'); + + await fireEvent.click(expirationSelect); + + const customOption = getByText('Custom'); + + expect(customOption).toBeInTheDocument(); + + await fireEvent.click(customOption); + + const customExpirationInput = getByPlaceholderText('yyyy-mm-dd'); + + expect(customExpirationInput).toBeInTheDocument(); + + await fireEvent.input(customExpirationInput, '2029-12-31'); + + await fireEvent.click(saveButton); + expect(getByText('***456')).toBeInTheDocument(); expect(getByText('API Key Created')).toBeInTheDocument(); @@ -78,16 +147,57 @@ describe('ApiKeyCreateOrEditModal', () => { expect(getByText('new api key')).toBeInTheDocument(); }); - test('should allow editing API key label', async () => { - apiKeysStore.apiKeys = [ - { - id: '123', - label: 'new api key', - apiKey: '123**', - createdAt: new Date().toString(), - updatedAt: new Date().toString(), + test('should allow creating API key with no expiration', async () => { + apiKeysStore.createApiKey.mockResolvedValue(testApiKey); + + const { getByText, getByPlaceholderText, getByTestId } = renderComponent({ + props: { + mode: 'new', }, - ]; + }); + + await retry(() => expect(getByText('Create API Key')).toBeInTheDocument()); + expect(getByText('Label')).toBeInTheDocument(); + + const inputLabel = getByPlaceholderText('e.g Internal Project'); + const saveButton = getByText('Save'); + const expirationSelect = getByTestId('expiration-select'); + + expect(inputLabel).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + expect(expirationSelect).toBeInTheDocument(); + + await fireEvent.update(inputLabel, 'new label'); + + await fireEvent.click(expirationSelect); + + const noExpirationOption = getByText('No Expiration'); + + expect(noExpirationOption).toBeInTheDocument(); + + await fireEvent.click(noExpirationOption); + + await fireEvent.click(saveButton); + + expect(getByText('API Key Created')).toBeInTheDocument(); + + expect(getByText('Done')).toBeInTheDocument(); + + expect( + getByText('Make sure to copy your API key now as you will not be able to see this again.'), + ).toBeInTheDocument(); + + expect(getByText('You can find more details in')).toBeInTheDocument(); + + expect(getByText('the API documentation')).toBeInTheDocument(); + + expect(getByText('Click to copy')).toBeInTheDocument(); + + expect(getByText('new api key')).toBeInTheDocument(); + }); + + test('should allow editing API key label', async () => { + apiKeysStore.apiKeys = [testApiKey]; apiKeysStore.updateApiKey.mockResolvedValue(); @@ -102,6 +212,12 @@ describe('ApiKeyCreateOrEditModal', () => { expect(getByText('Label')).toBeInTheDocument(); + const formattedDate = DateTime.fromMillis(Date.parse(testApiKey.createdAt)).toFormat( + 'ccc, MMM d yyyy', + ); + + expect(getByText(`API key was created on ${formattedDate}`)).toBeInTheDocument(); + const labelInput = getByTestId('api-key-label'); expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key'); diff --git a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue index 04139e84ad..2ce3eb050a 100644 --- a/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue +++ b/packages/editor-ui/src/components/ApiKeyCreateOrEditModal.vue @@ -11,12 +11,24 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useApiKeysStore } from '@/stores/apiKeys.store'; import { useToast } from '@/composables/useToast'; import type { BaseTextKey } from '@/plugins/i18n'; -import type { ApiKeyWithRawValue } from '@n8n/api-types'; +import { N8nText } from 'n8n-design-system'; +import { DateTime } from 'luxon'; +import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types'; + +const EXPIRATION_OPTIONS = { + '7_DAYS': 7, + '30_DAYS': 30, + '60_DAYS': 60, + '90_DAYS': 90, + CUSTOM: 1, + NO_EXPIRATION: 0, +}; const i18n = useI18n(); const { showError, showMessage } = useToast(); const uiStore = useUIStore(); +const rootStore = useRootStore(); const settingsStore = useSettingsStore(); const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore; const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore(); @@ -24,11 +36,43 @@ const { baseUrl } = useRootStore(); const documentTitle = useDocumentTitle(); const label = ref(''); +const expirationDaysFromNow = ref(EXPIRATION_OPTIONS['30_DAYS']); const modalBus = createEventBus(); const newApiKey = ref(null); const apiDocsURL = ref(''); const loading = ref(false); const rawApiKey = ref(''); +const customExpirationDate = ref(''); +const showExpirationDateSelector = ref(false); +const apiKeyCreationDate = ref(''); + +const calculateExpirationDate = (daysFromNow: number) => { + const date = DateTime.now() + .setZone(rootStore.timezone) + .startOf('day') + .plus({ days: daysFromNow }); + return date; +}; + +const getExpirationOptionLabel = (value: number) => { + if (EXPIRATION_OPTIONS.CUSTOM === value) { + return i18n.baseText('settings.api.view.modal.form.expiration.custom'); + } + + if (EXPIRATION_OPTIONS.NO_EXPIRATION === value) { + return i18n.baseText('settings.api.view.modal.form.expiration.none'); + } + + return i18n.baseText('settings.api.view.modal.form.expiration.days', { + interpolate: { + numberOfDays: value, + }, + }); +}; + +const expirationDate = ref( + calculateExpirationDate(expirationDaysFromNow.value).toFormat('ccc, MMM d yyyy'), +); const inputRef = ref(null); @@ -43,6 +87,17 @@ const props = withDefaults( }, ); +const allFormFieldsAreSet = computed(() => { + const isExpirationDateSet = + expirationDaysFromNow.value === EXPIRATION_OPTIONS.NO_EXPIRATION || + (expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) || + expirationDate.value; + + return label.value && (props.mode === 'edit' ? true : isExpirationDateSet); +}); + +const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime(); + onMounted(() => { documentTitle.set(i18n.baseText('settings.api')); @@ -51,7 +106,9 @@ onMounted(() => { }); if (props.mode === 'edit') { - label.value = apiKeysById[props.activeId]?.label ?? ''; + const apiKey = apiKeysById[props.activeId]; + label.value = apiKey.label ?? ''; + apiKeyCreationDate.value = getApiKeyCreationTime(apiKey); } apiDocsURL.value = isSwaggerUIEnabled @@ -63,6 +120,11 @@ function onInput(value: string): void { label.value = value; } +const getApiKeyCreationTime = (apiKey: ApiKey): string => { + const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy'); + return i18n.baseText('settings.api.creationTime', { interpolate: { time } }); +}; + async function onEdit() { try { loading.value = true; @@ -88,9 +150,22 @@ const onSave = async () => { return; } + let expirationUnixTimestamp = null; + + if (expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM) { + expirationUnixTimestamp = parseInt(customExpirationDate.value, 10); + } else if (expirationDaysFromNow.value !== EXPIRATION_OPTIONS.NO_EXPIRATION) { + expirationUnixTimestamp = calculateExpirationDate(expirationDaysFromNow.value).toUnixInteger(); + } + + const payload: CreateApiKeyRequestDto = { + label: label.value, + expiresAt: expirationUnixTimestamp, + }; + try { loading.value = true; - newApiKey.value = await createApiKey(label.value); + newApiKey.value = await createApiKey(payload); rawApiKey.value = newApiKey.value.rawApiKey; showMessage({ @@ -115,6 +190,23 @@ const modalTitle = computed(() => { } return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey); }); + +const onSelect = (value: number) => { + if (value === EXPIRATION_OPTIONS.CUSTOM) { + showExpirationDateSelector.value = true; + expirationDate.value = ''; + return; + } + + if (value !== EXPIRATION_OPTIONS.NO_EXPIRATION) { + expirationDate.value = calculateExpirationDate(value).toFormat('ccc, MMM d yyyy'); + showExpirationDateSelector.value = false; + return; + } + + expirationDate.value = ''; + showExpirationDateSelector.value = false; +}; - diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index e1ab15dfa3..fe445c8f6e 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1844,7 +1844,10 @@ "settings.api.delete.toast": "API Key deleted", "settings.api.create.toast": "API Key created", "settings.api.update.toast": "API Key updated", - "settings.api.creationTime": "Created {time}", + "settings.api.creationTime": "API key was created on {time}", + "settings.api.expirationTime": "Expires on {time}", + "settings.api.expired": "This API key has expired", + "settings.api.neverExpires": "Never expires", "settings.api.view.copy.toast": "API Key copied to clipboard", "settings.api.view.apiPlayground": "API Playground", "settings.api.view.info": "Use your API Key to control n8n programmatically using the {apiAction}. But if you only want to trigger workflows, consider using the {webhookAction} instead.", @@ -1856,7 +1859,12 @@ "settings.api.view.external-docs": "the API documentation", "settings.api.view.error": "Could not check if an api key already exists.", "settings.api.view.modal.form.label": "Label", + "settings.api.view.modal.form.expiration": "Expiration", + "settings.api.view.modal.form.expirationText": "The API key will expire on {expirationDate}", "settings.api.view.modal.form.label.placeholder": "e.g Internal Project", + "settings.api.view.modal.form.expiration.custom": "Custom", + "settings.api.view.modal.form.expiration.days": "{numberOfDays} days", + "settings.api.view.modal.form.expiration.none": "No Expiration", "settings.api.view.modal.title.created": "API Key Created", "settings.api.view.modal.title.create": "Create API Key", "settings.api.view.modal.title.edit": "Edit API Key", diff --git a/packages/editor-ui/src/stores/apiKeys.store.ts b/packages/editor-ui/src/stores/apiKeys.store.ts index 826f7bb0d1..0cc897aa75 100644 --- a/packages/editor-ui/src/stores/apiKeys.store.ts +++ b/packages/editor-ui/src/stores/apiKeys.store.ts @@ -5,7 +5,7 @@ import { useRootStore } from '@/stores/root.store'; import * as publicApiApi from '@/api/api-keys'; import { computed, ref } from 'vue'; import { useSettingsStore } from './settings.store'; -import type { ApiKey } from '@n8n/api-types'; +import type { ApiKey, CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { const apiKeys = ref([]); @@ -37,8 +37,8 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { return apiKeys.value; }; - const createApiKey = async (label: string) => { - const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, { label }); + const createApiKey = async (payload: CreateApiKeyRequestDto) => { + const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, payload); const { rawApiKey, ...rest } = newApiKey; apiKeys.value.push(rest); return newApiKey; @@ -49,9 +49,9 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { apiKeys.value = apiKeys.value.filter((apiKey) => apiKey.id !== id); }; - const updateApiKey = async (id: string, data: { label: string }) => { - await publicApiApi.updateApiKey(rootStore.restApiContext, id, data); - apiKeysById.value[id].label = data.label; + const updateApiKey = async (id: string, payload: UpdateApiKeyRequestDto) => { + await publicApiApi.updateApiKey(rootStore.restApiContext, id, payload); + apiKeysById.value[id].label = payload.label; }; return { diff --git a/packages/editor-ui/src/views/SettingsApiView.test.ts b/packages/editor-ui/src/views/SettingsApiView.test.ts index ed8342739d..b895af20e3 100644 --- a/packages/editor-ui/src/views/SettingsApiView.test.ts +++ b/packages/editor-ui/src/views/SettingsApiView.test.ts @@ -8,6 +8,7 @@ import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { setActivePinia } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; import { useApiKeysStore } from '@/stores/apiKeys.store'; +import { DateTime } from 'luxon'; setActivePinia(createTestingPinia()); @@ -50,6 +51,9 @@ describe('SettingsApiView', () => { }); it('if user public api enabled and there are API Keys in account, they should be rendered', async () => { + const dateInTheFuture = DateTime.now().plus({ days: 1 }); + const dateInThePast = DateTime.now().minus({ days: 1 }); + settingsStore.isPublicApiEnabled = true; cloudStore.userIsTrialing = false; apiKeysStore.apiKeys = [ @@ -59,17 +63,41 @@ describe('SettingsApiView', () => { createdAt: new Date().toString(), updatedAt: new Date().toString(), apiKey: '****Atcr', + expiresAt: null, + }, + { + id: '2', + label: 'test-key-2', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Bdcr', + expiresAt: dateInTheFuture.toSeconds(), + }, + { + id: '3', + label: 'test-key-3', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Wtcr', + expiresAt: dateInThePast.toSeconds(), }, ]; renderComponent(SettingsApiView); - expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument(); + expect(screen.getByText('Never expires')).toBeInTheDocument(); expect(screen.getByText('****Atcr')).toBeInTheDocument(); expect(screen.getByText('test-key-1')).toBeInTheDocument(); - expect(screen.getByText('Edit')).toBeInTheDocument(); - expect(screen.getByText('Delete')).toBeInTheDocument(); + expect( + screen.getByText(`Expires on ${dateInTheFuture.toFormat('ccc, MMM d yyyy')}`), + ).toBeInTheDocument(); + expect(screen.getByText('****Bdcr')).toBeInTheDocument(); + expect(screen.getByText('test-key-2')).toBeInTheDocument(); + + expect(screen.getByText('This API key has expired')).toBeInTheDocument(); + expect(screen.getByText('****Wtcr')).toBeInTheDocument(); + expect(screen.getByText('test-key-3')).toBeInTheDocument(); }); it('should show delete warning when trying to delete an API key', async () => { @@ -82,12 +110,13 @@ describe('SettingsApiView', () => { createdAt: new Date().toString(), updatedAt: new Date().toString(), apiKey: '****Atcr', + expiresAt: null, }, ]; renderComponent(SettingsApiView); - expect(screen.getByText(/Created \d+ seconds ago/)).toBeInTheDocument(); + expect(screen.getByText('Never expires')).toBeInTheDocument(); expect(screen.getByText('****Atcr')).toBeInTheDocument(); expect(screen.getByText('test-key-1')).toBeInTheDocument();