feat(core): Track last-used timestamp on API keys (#31236)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.22.3) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.15.0) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Ricardo Espinoza 2026-05-31 17:44:50 -04:00 committed by GitHub
parent 6cf3b0b679
commit ec83a0a944
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<boolean | null> {
const providedApiKey = req.headers[API_KEY_HEADER];

View File

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

View File

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

View File

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