mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 09:17:08 +02:00
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
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:
parent
6cf3b0b679
commit
ec83a0a944
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user