From ec83a0a944f7dca2f47c0b0725905ffc7e4b6629 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 31 May 2026 17:44:50 -0400 Subject: [PATCH] feat(core): Track last-used timestamp on API keys (#31236) --- packages/@n8n/api-types/src/api-keys.ts | 2 + packages/@n8n/db/src/entities/api-key.ts | 5 +- .../1784000000017-AddLastUsedAtToApiKey.ts | 11 +++++ .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../api-key-auth.strategy.integration.test.ts | 46 ++++++++++++++++++- .../cli/src/services/api-key-auth.strategy.ts | 18 ++++++++ .../cli/test/integration/api-keys.api.test.ts | 12 +++++ .../ApiKeyCreateOrEditModal.test.ts | 2 + .../apiKeys/views/SettingsApiView.test.ts | 7 +++ 10 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1784000000017-AddLastUsedAtToApiKey.ts diff --git a/packages/@n8n/api-types/src/api-keys.ts b/packages/@n8n/api-types/src/api-keys.ts index 5038341974b..aa9d47d512f 100644 --- a/packages/@n8n/api-types/src/api-keys.ts +++ b/packages/@n8n/api-types/src/api-keys.ts @@ -12,6 +12,8 @@ export type ApiKey = { /** Null if API key never expires */ expiresAt: UnixTimestamp | null; scopes: ApiKeyScope[]; + /** ISO timestamp of the last time the key authenticated a request, or null if never used. */ + lastUsedAt: string | null; }; export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string }; diff --git a/packages/@n8n/db/src/entities/api-key.ts b/packages/@n8n/db/src/entities/api-key.ts index 83222fbe752..30b8cfcd447 100644 --- a/packages/@n8n/db/src/entities/api-key.ts +++ b/packages/@n8n/db/src/entities/api-key.ts @@ -2,7 +2,7 @@ import type { ApiKeyScope } from '@n8n/permissions'; import { Column, Entity, Index, ManyToOne, Unique } from '@n8n/typeorm'; import { ApiKeyAudience } from 'n8n-workflow'; -import { JsonColumn, WithTimestampsAndStringId } from './abstract-entity'; +import { DateTimeColumn, JsonColumn, WithTimestampsAndStringId } from './abstract-entity'; import { User } from './user'; @Entity('user_api_keys') @@ -30,4 +30,7 @@ export class ApiKey extends WithTimestampsAndStringId { @Column({ type: String, default: 'public-api' }) audience: ApiKeyAudience; + + @DateTimeColumn({ nullable: true }) + lastUsedAt: Date | null; } diff --git a/packages/@n8n/db/src/migrations/common/1784000000017-AddLastUsedAtToApiKey.ts b/packages/@n8n/db/src/migrations/common/1784000000017-AddLastUsedAtToApiKey.ts new file mode 100644 index 00000000000..f58da86718b --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000017-AddLastUsedAtToApiKey.ts @@ -0,0 +1,11 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddLastUsedAtToApiKey1784000000017 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('user_api_keys', [column('lastUsedAt').timestampTimezone()]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('user_api_keys', ['lastUsedAt']); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 3006e100b8f..9b5dbe6799a 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -192,6 +192,7 @@ import { SplitRedactionScopeInCustomRoles1784000000013 } from '../common/1784000 import { PersistInstanceAiPendingConfirmations1784000000014 } from '../common/1784000000014-PersistInstanceAiPendingConfirmations'; import { AddSourceWorkflowIdToWorkflow1784000000015 } from '../common/1784000000015-AddSourceWorkflowIdToWorkflow'; import { UseSlugAsPrimaryKeyInMcpRegistryServer1784000000016 } from '../common/1784000000016-UseSlugAsPrimaryKeyInMcpRegistryServer'; +import { AddLastUsedAtToApiKey1784000000017 } from '../common/1784000000017-AddLastUsedAtToApiKey'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -389,4 +390,5 @@ export const postgresMigrations: Migration[] = [ PersistInstanceAiPendingConfirmations1784000000014, AddSourceWorkflowIdToWorkflow1784000000015, UseSlugAsPrimaryKeyInMcpRegistryServer1784000000016, + AddLastUsedAtToApiKey1784000000017, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 3afb57a96c8..436d7c8aa98 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -185,6 +185,7 @@ import { SplitRedactionScopeInCustomRoles1784000000013 } from '../common/1784000 import { PersistInstanceAiPendingConfirmations1784000000014 } from '../common/1784000000014-PersistInstanceAiPendingConfirmations'; import { AddSourceWorkflowIdToWorkflow1784000000015 } from '../common/1784000000015-AddSourceWorkflowIdToWorkflow'; import { UseSlugAsPrimaryKeyInMcpRegistryServer1784000000016 } from '../common/1784000000016-UseSlugAsPrimaryKeyInMcpRegistryServer'; +import { AddLastUsedAtToApiKey1784000000017 } from '../common/1784000000017-AddLastUsedAtToApiKey'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -375,6 +376,7 @@ const sqliteMigrations: Migration[] = [ PersistInstanceAiPendingConfirmations1784000000014, AddSourceWorkflowIdToWorkflow1784000000015, UseSlugAsPrimaryKeyInMcpRegistryServer1784000000016, + AddLastUsedAtToApiKey1784000000017, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts index b1c4a0749af..2eeacc851a3 100644 --- a/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts +++ b/packages/cli/src/services/__tests__/api-key-auth.strategy.integration.test.ts @@ -1,3 +1,4 @@ +import { Logger } from '@n8n/backend-common'; import { testDb } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest, User } from '@n8n/db'; import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; @@ -9,6 +10,7 @@ import { randomString } from 'n8n-workflow'; import { TOKEN_EXCHANGE_ISSUER } from '@/modules/token-exchange/token-exchange.types'; import { createOwnerWithApiKey } from '@test-integration/db/users'; +import { retryUntil } from '@test-integration/retry-until'; import { ApiKeyAuthStrategy } from '../api-key-auth.strategy'; import { JwtService } from '../jwt.service'; @@ -55,7 +57,11 @@ describe('ApiKeyAuthStrategy', () => { beforeAll(async () => { await testDb.init(); jwtService = Container.get(JwtService); - strategy = new ApiKeyAuthStrategy(Container.get(ApiKeyRepository), jwtService); + strategy = new ApiKeyAuthStrategy( + Container.get(ApiKeyRepository), + jwtService, + Container.get(Logger), + ); }); beforeEach(async () => { @@ -121,6 +127,44 @@ describe('ApiKeyAuthStrategy', () => { expect(await strategy.buildTokenGrant(disabledOwner.apiKeys[0].apiKey)).toBe(false); }); + describe('lastUsedAt tracking', () => { + it('updates lastUsedAt on the API key record after a successful auth', async () => { + const owner = await createOwnerWithApiKey(); + const [{ apiKey, id: apiKeyId }] = owner.apiKeys; + const repo = Container.get(ApiKeyRepository); + + expect((await repo.findOneByOrFail({ id: apiKeyId })).lastUsedAt).toBeNull(); + + const grant = await strategy.buildTokenGrant(apiKey); + expect(grant).toBeTruthy(); + + const updated = await retryUntil(async () => { + const record = await repo.findOneByOrFail({ id: apiKeyId }); + expect(record.lastUsedAt).toBeInstanceOf(Date); + return record; + }); + expect(updated.lastUsedAt).toBeInstanceOf(Date); + }); + + it('skips the lastUsedAt update while still within the throttle window', async () => { + const owner = await createOwnerWithApiKey(); + const [{ apiKey, id: apiKeyId }] = owner.apiKeys; + const repo = Container.get(ApiKeyRepository); + + const recent = new Date(); + await repo.update({ id: apiKeyId }, { lastUsedAt: recent }); + + const updateSpy = jest.spyOn(repo, 'update'); + + const grant = await strategy.buildTokenGrant(apiKey); + expect(grant).toBeTruthy(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(updateSpy).not.toHaveBeenCalled(); + updateSpy.mockRestore(); + }); + }); + it('rethrows non-TokenExpiredError from JWT verification', async () => { const owner = await createOwnerWithApiKey(); const [{ apiKey }] = owner.apiKeys; diff --git a/packages/cli/src/services/api-key-auth.strategy.ts b/packages/cli/src/services/api-key-auth.strategy.ts index 5038fdf45b9..3647b81d457 100644 --- a/packages/cli/src/services/api-key-auth.strategy.ts +++ b/packages/cli/src/services/api-key-auth.strategy.ts @@ -1,3 +1,5 @@ +import { Logger } from '@n8n/backend-common'; +import { Time } from '@n8n/constants'; import type { AuthenticatedRequest, TokenGrant } from '@n8n/db'; import { ApiKeyRepository } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -8,12 +10,14 @@ import { JwtService } from './jwt.service'; import { API_KEY_AUDIENCE, API_KEY_ISSUER, PREFIX_LEGACY_API_KEY } from './public-api-key.service'; const API_KEY_HEADER = 'x-n8n-api-key'; +const LAST_USED_AT_THROTTLE_MS = 1 * Time.minutes.toMilliseconds; @Service() export class ApiKeyAuthStrategy implements AuthStrategy { constructor( private readonly apiKeyRepository: ApiKeyRepository, private readonly jwtService: JwtService, + private readonly logger: Logger, ) {} async buildTokenGrant( @@ -56,6 +60,8 @@ export class ApiKeyAuthStrategy implements AuthStrategy { } } + this.touchLastUsedAt(apiKeyRecord.id, apiKeyRecord.lastUsedAt); + return { scopes: apiKeyRecord.user.role.scopes.map((s) => s.slug), subject: apiKeyRecord.user, @@ -63,6 +69,18 @@ export class ApiKeyAuthStrategy implements AuthStrategy { }; } + private touchLastUsedAt(apiKeyId: string, previous: Date | null) { + const previousMs = previous?.getTime() ?? 0; + if (Date.now() - previousMs < LAST_USED_AT_THROTTLE_MS) return; + + // Best-effort: never block auth if the write fails, but log so we don't hide bugs. + void this.apiKeyRepository + .update({ id: apiKeyId }, { lastUsedAt: new Date() }) + .catch((error) => { + this.logger.warn('Failed to update lastUsedAt on API key', { apiKeyId, error }); + }); + } + async authenticate(req: AuthenticatedRequest): Promise { const providedApiKey = req.headers[API_KEY_HEADER]; diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index a924b96a0d6..c283b372542 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -82,6 +82,7 @@ describe('Owner shell', () => { updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKey.expiresAt).toBeNull(); @@ -122,6 +123,7 @@ describe('Owner shell', () => { updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); @@ -154,6 +156,7 @@ describe('Owner shell', () => { updatedAt: expect.any(Date), scopes: ['user:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); @@ -186,6 +189,7 @@ describe('Owner shell', () => { updatedAt: expect.any(Date), scopes: ['user:create'], audience: 'public-api', + lastUsedAt: null, }); }); @@ -215,6 +219,7 @@ describe('Owner shell', () => { updatedAt: expect.any(Date), scopes: ['user:create', 'workflow:create'], audience: 'public-api', + lastUsedAt: null, }); }); @@ -271,6 +276,7 @@ describe('Owner shell', () => { expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ @@ -283,6 +289,7 @@ describe('Owner shell', () => { expiresAt: null, scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); }); @@ -347,6 +354,7 @@ describe('Member', () => { updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKeyResponse.body.data.expiresAt).toBeNull(); @@ -379,6 +387,7 @@ describe('Member', () => { updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); @@ -411,6 +420,7 @@ describe('Member', () => { updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); @@ -461,6 +471,7 @@ describe('Member', () => { expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ @@ -473,6 +484,7 @@ describe('Member', () => { expiresAt: null, scopes: ['workflow:create'], audience: 'public-api', + lastUsedAt: null, }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts index 51fee9d7dff..4445d717253 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/components/ApiKeyCreateOrEditModal.test.ts @@ -31,6 +31,7 @@ const testApiKey: ApiKeyWithRawValue = { rawApiKey: '123456', expiresAt: 0, scopes: ['user:create', 'user:list'], + lastUsedAt: null, }; const apiKeysStore = mockedStore(useApiKeysStore); @@ -89,6 +90,7 @@ describe('ApiKeyCreateOrEditModal', () => { rawApiKey: '***456', expiresAt: 0, scopes: ['user:create', 'user:list'], + lastUsedAt: null, }); const { getByText, getByPlaceholderText, getByTestId } = renderComponent({ diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts index d63023d4fd9..280d06498c1 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts @@ -108,6 +108,7 @@ describe('SettingsApiView', () => { apiKey: '****Atcr', expiresAt: null, scopes: ['user:create'], + lastUsedAt: null, }, { id: '2', @@ -117,6 +118,7 @@ describe('SettingsApiView', () => { apiKey: '****Bdcr', expiresAt: dateInTheFuture.toSeconds(), scopes: ['user:create'], + lastUsedAt: null, }, { id: '3', @@ -126,6 +128,7 @@ describe('SettingsApiView', () => { apiKey: '****Wtcr', expiresAt: dateInThePast.toSeconds(), scopes: ['user:create'], + lastUsedAt: null, }, ]; @@ -167,6 +170,7 @@ describe('SettingsApiView', () => { apiKey: '****Atcr', expiresAt: null, scopes: ['user:create'], + lastUsedAt: null, }, { id: '2', @@ -176,6 +180,7 @@ describe('SettingsApiView', () => { apiKey: '****Bdcr', expiresAt: dateInTheFuture.toSeconds(), scopes: ['user:create'], + lastUsedAt: null, }, { id: '3', @@ -185,6 +190,7 @@ describe('SettingsApiView', () => { apiKey: '****Wtcr', expiresAt: dateInThePast.toSeconds(), scopes: ['user:create'], + lastUsedAt: null, }, ]; @@ -219,6 +225,7 @@ describe('SettingsApiView', () => { apiKey: '****Atcr', expiresAt: null, scopes: ['user:create'], + lastUsedAt: null, }, ];