diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts
index eb97575e..76c2184c 100644
--- a/backend/src/__tests__/api-tokens.test.ts
+++ b/backend/src/__tests__/api-tokens.test.ts
@@ -1,30 +1,34 @@
/**
- * Tests for API token scope enforcement, blocked endpoints, expiration, and revocation.
+ * Tests for API token scope enforcement, blocked endpoints, expiration,
+ * revocation, creation validation, limits, and ownership.
*/
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
-import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
+import bcrypt from 'bcrypt';
+import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
+let authCookie: string;
-/** Create an API token in the DB and return its raw JWT string. */
+/** Create an API token directly in the DB and return its raw JWT string. */
function createTestApiToken(
scope: 'read-only' | 'deploy-only' | 'full-admin',
expiresAt: number | null = null,
+ userId?: number,
): string {
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
- const user = db.getUserByUsername('testadmin');
+ const resolvedUserId = userId ?? db.getUserByUsername('testadmin')!.id;
db.addApiToken({
token_hash: tokenHash,
name: `test-${scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
scope,
- user_id: user!.id,
+ user_id: resolvedUserId,
created_at: Date.now(),
expires_at: expiresAt,
});
@@ -34,14 +38,22 @@ function createTestApiToken(
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
+
+ // Mock LicenseService to return paid/admiral for Admiral-gated routes
+ const { LicenseService } = await import('../services/LicenseService');
+ vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
+ vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
+ vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
+
({ app } = await import('../index'));
+ authCookie = await loginAsTestAdmin(app);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
-// ─── Scope Enforcement Middleware ────────────────────────────────────────────
+// --- Scope Enforcement Middleware ---
describe('enforceApiTokenScope', () => {
it('read-only token allows GET requests', async () => {
@@ -98,7 +110,7 @@ describe('enforceApiTokenScope', () => {
});
});
-// ─── Blocked Endpoints (human-session-only) ─────────────────────────────────
+// --- Blocked Endpoints (human-session-only) ---
describe('API token blocked endpoints', () => {
let fullAdminToken: string;
@@ -136,6 +148,12 @@ describe('API token blocked endpoints', () => {
{ method: 'get', path: '/api/api-tokens' },
{ method: 'post', path: '/api/api-tokens', body: { name: 'test', scope: 'read-only' } },
{ method: 'delete', path: '/api/api-tokens/1' },
+ // Registry management
+ { method: 'get', path: '/api/registries' },
+ { method: 'post', path: '/api/registries', body: { name: 'test', type: 'dockerhub', url: 'https://index.docker.io' } },
+ { method: 'put', path: '/api/registries/1', body: { name: 'updated' } },
+ { method: 'delete', path: '/api/registries/1' },
+ { method: 'post', path: '/api/registries/1/test' },
];
for (const { method, path, body } of blockedEndpoints) {
@@ -149,7 +167,7 @@ describe('API token blocked endpoints', () => {
}
});
-// ─── Token Expiration ───────────────────────────────────────────────────────
+// --- Token Expiration ---
describe('API token expiration', () => {
it('expired token returns 401', async () => {
@@ -169,7 +187,7 @@ describe('API token expiration', () => {
});
});
-// ─── Token Revocation ───────────────────────────────────────────────────────
+// --- Token Revocation ---
describe('API token revocation', () => {
it('revoked token returns 401', async () => {
@@ -187,3 +205,196 @@ describe('API token revocation', () => {
expect(res.status).toBe(401);
});
});
+
+// --- Creation Validation ---
+
+describe('API token creation validation', () => {
+ it('rejects missing name', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ scope: 'read-only' });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/name/i);
+ });
+
+ it('rejects empty/whitespace name', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: ' ', scope: 'read-only' });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/name/i);
+ });
+
+ it('rejects name longer than 100 characters', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: 'a'.repeat(101), scope: 'read-only' });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/100/);
+ });
+
+ it('rejects invalid scope', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: 'test-invalid-scope', scope: 'superadmin' });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/scope/i);
+ });
+
+ it('rejects invalid expiry value', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: 'test-invalid-expiry', scope: 'read-only', expires_in: 7 });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/expires_in/i);
+ });
+
+ it('accepts null expiry (no expiration)', async () => {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: `test-null-expiry-${Date.now()}`, scope: 'read-only', expires_in: null });
+ expect(res.status).toBe(201);
+ expect(res.body.token).toBeDefined();
+ });
+
+ it('accepts valid expiry values', async () => {
+ for (const days of [30, 60, 90, 365]) {
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: `test-expiry-${days}-${Date.now()}`, scope: 'read-only', expires_in: days });
+ expect(res.status).toBe(201);
+ }
+ });
+});
+
+// --- Token Count Limit ---
+
+describe('API token count limit', () => {
+ it('rejects creation when user has 25 active tokens', async () => {
+ const db = DatabaseService.getInstance();
+ const user = db.getUserByUsername('testadmin')!;
+
+ // Seed up to 25 active tokens using the shared helper
+ const existing = db.getActiveApiTokenCountByUser(user.id);
+ const toCreate = 25 - existing;
+ for (let i = 0; i < toCreate; i++) {
+ createTestApiToken('read-only', null, user.id);
+ }
+
+ expect(db.getActiveApiTokenCountByUser(user.id)).toBeGreaterThanOrEqual(25);
+
+ const res = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: `overflow-${Date.now()}`, scope: 'read-only' });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/maximum/i);
+ });
+});
+
+// --- Token Name Uniqueness ---
+
+describe('API token name uniqueness', () => {
+ it('rejects duplicate token name for same user', async () => {
+ // Revoke existing tokens to make room (limit test may have filled to 25)
+ const db = DatabaseService.getInstance();
+ const user = db.getUserByUsername('testadmin')!;
+ const existing = db.getApiTokensByUser(user.id);
+ for (const t of existing) {
+ if (!t.revoked_at) db.revokeApiToken(t.id);
+ }
+
+ const uniqueName = `dup-test-${Date.now()}`;
+
+ // Create the first token
+ const res1 = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: uniqueName, scope: 'read-only' });
+ expect(res1.status).toBe(201);
+
+ // Attempt to create another with the same name
+ const res2 = await request(app)
+ .post('/api/api-tokens')
+ .set('Cookie', authCookie)
+ .send({ name: uniqueName, scope: 'read-only' });
+ expect(res2.status).toBe(409);
+ expect(res2.body.error).toMatch(/already exists/i);
+ });
+});
+
+// --- last_used_at Tracking ---
+
+describe('API token last_used_at tracking', () => {
+ it('updates last_used_at on API token usage', async () => {
+ const rawToken = createTestApiToken('read-only');
+ const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
+ const db = DatabaseService.getInstance();
+
+ // Verify last_used_at is null initially
+ const before = db.getApiTokenByHash(tokenHash)!;
+ expect(before.last_used_at).toBeNull();
+
+ // Make a request with the token
+ await request(app)
+ .get('/api/stacks')
+ .set('Authorization', `Bearer ${rawToken}`);
+
+ // Verify last_used_at is now set
+ const after = db.getApiTokenByHash(tokenHash)!;
+ expect(after.last_used_at).toBeTypeOf('number');
+ expect(after.last_used_at!).toBeGreaterThan(0);
+ });
+});
+
+// --- Ownership Constraint ---
+
+describe('API token ownership', () => {
+ it('returns 403 when deleting another user\'s token', async () => {
+ const db = DatabaseService.getInstance();
+
+ // Create a second user
+ const otherHash = await bcrypt.hash('otherpass123', 1);
+ db.addUser({ username: 'otheruser', password_hash: otherHash, role: 'admin' });
+ const otherUser = db.getUserByUsername('otheruser')!;
+
+ // Create a token for the other user using the shared helper
+ const rawToken = createTestApiToken('read-only', null, otherUser.id);
+ const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
+ const apiToken = db.getApiTokenByHash(hash)!;
+ const tokenId = apiToken.id;
+
+ // Try to delete it as testadmin
+ const res = await request(app)
+ .delete(`/api/api-tokens/${tokenId}`)
+ .set('Cookie', authCookie);
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/your own/i);
+ });
+});
+
+// --- Delete Edge Cases ---
+
+describe('API token delete edge cases', () => {
+ it('returns 404 for nonexistent token ID', async () => {
+ const res = await request(app)
+ .delete('/api/api-tokens/99999')
+ .set('Cookie', authCookie);
+ expect(res.status).toBe(404);
+ });
+
+ it('returns 400 for non-numeric token ID', async () => {
+ const res = await request(app)
+ .delete('/api/api-tokens/abc')
+ .set('Cookie', authCookie);
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/invalid/i);
+ });
+});
diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts
index 8eedf0e4..d32a684e 100644
--- a/backend/src/__tests__/helpers/setupTestDb.ts
+++ b/backend/src/__tests__/helpers/setupTestDb.ts
@@ -40,6 +40,19 @@ export async function setupTestDb(): Promise
@@ -101,7 +106,9 @@ Click the trash icon next to any token in the API Tokens settings tab. A confirm
## Security model
- **Hashed storage**: Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted.
+- **JWT-level expiry ceiling**: Every token includes a built-in expiry at the JWT level as defense-in-depth, independent of the user-configured expiration. The database-level expiry is always the tighter constraint.
- **Audit trail**: All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username.
- **Optional expiry**: Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed.
+- **Per-user limits**: Each user can create up to 25 active tokens. Token names must be unique among active tokens for the same user.
- **Usage tracking**: Each token tracks when it was last used, visible in the token list.
- **Scope enforcement**: Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints.
diff --git a/docs/images/api-tokens/api-tokens-overview.png b/docs/images/api-tokens/api-tokens-overview.png
index 98ec2a05..4cc175a0 100644
Binary files a/docs/images/api-tokens/api-tokens-overview.png and b/docs/images/api-tokens/api-tokens-overview.png differ
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index d67acac3..c72f6bb6 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1303,7 +1303,7 @@ paths:
**Note:** API tokens cannot create other API tokens.
responses:
"201":
- description: Token created. The `token` field contains the full JWT — save it now, it won't be shown again.
+ description: Token created. The `token` field contains the full JWT. Save it now; it will not be shown again.
content:
application/json:
schema:
@@ -1316,13 +1316,19 @@ paths:
type: string
description: Full JWT token. Store securely — this is the only time it's returned.
"400":
- description: Validation error.
+ description: Validation error (missing/invalid fields, or maximum of 25 active tokens reached).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
$ref: "#/components/responses/Forbidden"
+ "409":
+ description: An active token with this name already exists.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
requestBody:
@@ -1335,7 +1341,7 @@ paths:
properties:
name:
type: string
- description: Human-readable token name.
+ description: Human-readable token name. Must be unique among the user's active tokens.
maxLength: 100
example: CI/CD Deploy Token
scope:
diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx
index d963ee59..0a3a8ac2 100644
--- a/frontend/src/components/ApiTokensSection.tsx
+++ b/frontend/src/components/ApiTokensSection.tsx
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Combobox } from '@/components/ui/combobox';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
@@ -101,7 +101,8 @@ export function ApiTokensSection() {
toast.error(err?.error || err?.message || 'Failed to create token.');
}
} catch (e: unknown) {
- toast.error((e as Error)?.message || 'Network error.');
+ const message = e instanceof Error ? e.message : 'Network error.';
+ toast.error(message);
} finally { setCreating(false); }
};
@@ -118,9 +119,13 @@ export function ApiTokensSection() {
} catch { toast.error('Network error.'); }
};
- const copyToClipboard = (text: string, label: string) => {
- navigator.clipboard.writeText(text);
- toast.success(`${label} copied to clipboard.`);
+ const copyToClipboard = async (text: string, label: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success(`${label} copied to clipboard.`);
+ } catch {
+ toast.error('Failed to copy to clipboard.');
+ }
};
return (
@@ -137,7 +142,7 @@ export function ApiTokensSection() {
{newToken.token}