Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ AUTH_RATE_LIMIT_MAX=10
# Time window in milliseconds for auth rate limiting (default: 60 seconds)
AUTH_RATE_LIMIT_WINDOW_MS=60000

# ------------------------------------------------------------
# API Key Expiry
# Optional: Default lifetime (in whole days) for newly created API keys.
# When set to a positive integer, createApiKey() computes expiresAt as
# now + API_KEY_DEFAULT_EXPIRY_DAYS * 86400 seconds
# unless the caller supplies an explicit expiresAt.
# Omit the variable (or set it to 0) to create non-expiring keys.
# Expired keys receive status EXPIRED on their first failed validation attempt
# and return HTTP 401 "API key has expired" on every subsequent call.
# ------------------------------------------------------------
API_KEY_DEFAULT_EXPIRY_DAYS=

# ------------------------------------------------------------
# OpenTelemetry / Tracing
# Optional: Set OTEL_ENABLED=true to activate distributed tracing.
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,7 @@ User authentication is orchestrated via the auth service and integrates with Web
Key authentication-related environment variables (when applicable):

- `AUTH_PROVIDER` — Identity provider (e.g., CLERK, BETTER_AUTH)
- `JWT_SECRET` — (Future) JWT signing secret
- `API_KEY_EXPIRY_DAYS` — (Future) Default API key expiry duration in days
- `API_KEY_DEFAULT_EXPIRY_DAYS` — Optional. When set, newly created API keys expire after this many days. Omit (or set to `0`) for non-expiring keys. See [API Key Expiry](#api-key-expiry) below.
- `RATE_LIMIT_RPM` — Requests per minute limit (per API key)

---
Expand Down
170 changes: 170 additions & 0 deletions src/api-keys/api-key.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,174 @@ describe('ApiKeyService', () => {
expect(newResult.apiKey.status).toBe(ApiKeyStatus.ACTIVE);
});
});

// ---------------------------------------------------------------------------
// API_KEY_DEFAULT_EXPIRY_DAYS enforcement
// ---------------------------------------------------------------------------

describe('createApiKey — API_KEY_DEFAULT_EXPIRY_DAYS', () => {
let serviceWithExpiry: ApiKeyService;
let prismaWithExpiry: any;
let keysWithExpiry: any[];

beforeEach(async () => {
keysWithExpiry = [];

prismaWithExpiry = {
project: {
findUnique: jest.fn().mockResolvedValue({
id: 'project-expiry',
environment: 'development',
developerId: 'developer-expiry',
}),
},
apiKey: {
create: jest.fn().mockImplementation(async ({ data }) => {
const key = {
id: `expiry-key-${keysWithExpiry.length + 1}`,
name: data.name,
keyHash: data.keyHash,
keyPrefix: data.keyPrefix,
lastFour: data.lastFour,
projectId: data.projectId,
status: data.status,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: data.expiresAt ?? null,
lastUsedAt: null,
revokedAt: null,
revokedReason: null,
gracePeriodEndsAt: null,
network: null,
};
keysWithExpiry.push(key);
return key;
}),
findUnique: jest.fn().mockImplementation(async ({ where }) => {
if (where?.id) {
return (
keysWithExpiry.find((k) => k.id === where.id) || null
);
}
if (where?.keyHash) {
const key = keysWithExpiry.find(
(k) => k.keyHash === where.keyHash,
);
if (!key) return null;
return {
...key,
project: {
id: key.projectId,
developerId: 'developer-expiry',
developer: { id: 'developer-expiry' },
},
};
}
return null;
}),
update: jest.fn().mockImplementation(async ({ where, data }) => {
const key = keysWithExpiry.find((k) => k.id === where.id);
if (key) Object.assign(key, data);
return key;
}),
},
apiKeyUsage: { create: jest.fn() },
};

// ConfigService that returns API_KEY_DEFAULT_EXPIRY_DAYS = 30
const configWithExpiry = {
get: jest.fn((key: string) => {
if (key === 'API_KEY_ROTATION_GRACE_SECONDS') return 3600;
if (key === 'API_KEY_DEFAULT_EXPIRY_DAYS') return 30;
return undefined;
}),
};

const module = await Test.createTestingModule({
providers: [
ApiKeyService,
{ provide: ConfigService, useValue: configWithExpiry },
],
}).compile();

serviceWithExpiry = module.get<ApiKeyService>(ApiKeyService);
serviceWithExpiry['prisma'] = prismaWithExpiry;
});

it('sets expiresAt ~30 days in the future when API_KEY_DEFAULT_EXPIRY_DAYS=30 and caller omits expiresAt', async () => {
const before = Date.now();
const result = await serviceWithExpiry.createApiKey({
name: 'auto-expiry-key',
projectId: 'project-expiry',
});
const after = Date.now();

expect(result.apiKey.expiresAt).toBeDefined();
const expires = result.apiKey.expiresAt!.getTime();
const expectedMin = before + 30 * 24 * 60 * 60 * 1000;
const expectedMax = after + 30 * 24 * 60 * 60 * 1000;

expect(expires).toBeGreaterThanOrEqual(expectedMin);
expect(expires).toBeLessThanOrEqual(expectedMax);
});

it('explicit expiresAt from caller overrides the default expiry', async () => {
const explicitExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

const result = await serviceWithExpiry.createApiKey({
name: 'explicit-expiry-key',
projectId: 'project-expiry',
expiresAt: explicitExpiry,
});

// Should be within 1 second of the explicit date
expect(
Math.abs(result.apiKey.expiresAt!.getTime() - explicitExpiry.getTime()),
).toBeLessThan(1000);
});

it('no expiresAt is set when API_KEY_DEFAULT_EXPIRY_DAYS=0 (default, non-expiring)', async () => {
// service from outer scope has defaultExpiryDays=0
const result = await service.createApiKey({
name: 'non-expiring-key',
projectId: 'project-123',
});

expect(result.apiKey.expiresAt).toBeUndefined();
});

it('key created with default expiry is rejected after it expires', async () => {
// Create a key with a very short explicit expiry (already in the past)
const result = await serviceWithExpiry.createApiKey({
name: 'already-expired-default',
projectId: 'project-expiry',
expiresAt: new Date(Date.now() - 1), // already expired
});

// validateApiKey should throw UnauthorizedException
await expect(
serviceWithExpiry.validateApiKey(result.plainTextKey),
).rejects.toThrow(UnauthorizedException);
});

it('validateApiKey marks the key as EXPIRED in the DB when expiry has passed', async () => {
const result = await serviceWithExpiry.createApiKey({
name: 'mark-expired-key',
projectId: 'project-expiry',
expiresAt: new Date(Date.now() - 1),
});

try {
await serviceWithExpiry.validateApiKey(result.plainTextKey);
} catch {
// expected 401
}

// The update call should have set status = EXPIRED
const updateCall = prismaWithExpiry.apiKey.update.mock.calls.find(
(call: any[]) => call[0]?.data?.status === ApiKeyStatus.EXPIRED,
);
expect(updateCall).toBeDefined();
});
});
});
8 changes: 7 additions & 1 deletion src/api-keys/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ export class ApiKeyService implements OnModuleDestroy {
private readonly logger = new SafeLogger(ApiKeyService.name);
private prisma: PrismaClient;
private readonly gracePeriodSeconds: number;
/** Default lifetime (days) for new API keys; 0 means non-expiring. */
private readonly defaultExpiryDays: number;

constructor(private readonly configService: ConfigService) {
this.prisma = new PrismaClient({} as any);
this.gracePeriodSeconds =
this.configService.get<number>('API_KEY_ROTATION_GRACE_SECONDS') ?? 3600;
this.defaultExpiryDays =
this.configService.get<number>('API_KEY_DEFAULT_EXPIRY_DAYS') ?? 0;
}

async onModuleDestroy() {
Expand Down Expand Up @@ -92,7 +96,9 @@ export class ApiKeyService implements OnModuleDestroy {

const expiresAt = request.expiresAt
? new Date(request.expiresAt)
: undefined;
: this.defaultExpiryDays > 0
? new Date(Date.now() + this.defaultExpiryDays * 24 * 60 * 60 * 1000)
: undefined;

// Store hashed key
const apiKey = await this.prisma.apiKey.create({
Expand Down
16 changes: 16 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface ValidatedEnv {
RATE_LIMIT_SENSITIVE_WINDOW_MS: number;
RATE_LIMIT_SENSITIVE_MAX_REQUESTS: number;
API_KEY_ROTATION_GRACE_SECONDS: number;
/**
* Optional default lifetime (in whole days) applied to every newly created
* API key when the caller does not supply an explicit `expiresAt`.
* `0` means no expiry (non-expiring keys). Defaults to `0`.
*/
API_KEY_DEFAULT_EXPIRY_DAYS: number;
KEY_MGMT_MAX_RETRIES: number;
KEY_MGMT_RETRY_BACKOFF_MS: number;
BLOCK_SELF_PAYMENTS: boolean;
Expand Down Expand Up @@ -483,6 +489,15 @@ export function validateEnv(env: NodeJS.ProcessEnv): ValidatedEnv {
{ min: 0 },
violations,
);
// Optional default lifetime for newly created API keys (in whole days).
// 0 means non-expiring (the historical default).
const API_KEY_DEFAULT_EXPIRY_DAYS = optionalInt(
env,
'API_KEY_DEFAULT_EXPIRY_DAYS',
0,
{ min: 0, max: 3650 }, // cap at 10 years
violations,
);
const KEY_MGMT_MAX_RETRIES = optionalInt(
env,
'KEY_MGMT_MAX_RETRIES',
Expand Down Expand Up @@ -655,6 +670,7 @@ export function validateEnv(env: NodeJS.ProcessEnv): ValidatedEnv {
RATE_LIMIT_SENSITIVE_WINDOW_MS,
RATE_LIMIT_SENSITIVE_MAX_REQUESTS,
API_KEY_ROTATION_GRACE_SECONDS,
API_KEY_DEFAULT_EXPIRY_DAYS,
KEY_MGMT_MAX_RETRIES,
KEY_MGMT_RETRY_BACKOFF_MS,
BLOCK_SELF_PAYMENTS,
Expand Down
Loading
Loading