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 { return tmpDir; } +/** + * Log in as the seeded test admin and return the session cookie string. + * Requires `app` to be the Express instance from `index.ts`. + */ +export async function loginAsTestAdmin(app: import('express').Express): Promise { + const supertest = (await import('supertest')).default; + const res = await supertest(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + const cookies = res.headers['set-cookie'] as string | string[]; + return Array.isArray(cookies) ? cookies[0] : cookies; +} + export function cleanupTestDb(tmpDir: string): void { try { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 74ca3f32..80607a35 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -441,10 +441,12 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); if (!apiToken || apiToken.revoked_at) { + if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked'); res.status(401).json({ error: 'API token not found or revoked' }); return; } if (apiToken.expires_at && apiToken.expires_at < Date.now()) { + if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired'); res.status(401).json({ error: 'API token has expired' }); return; } @@ -457,6 +459,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): }; req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer', userId: apiToken.user_id }; req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin'; + if (isDebugEnabled()) console.log('[Auth:diag] API token authenticated:', { scope: apiToken.scope, user: creator?.username, tokenName: apiToken.name }); next(); return; } @@ -1123,10 +1126,12 @@ const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => { const scope = req.apiTokenScope; if (!scope) { next(); return; } // Not an API token request + if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope); if (scope === 'full-admin') { next(); return; } if (scope === 'read-only') { if (req.method !== 'GET') { + if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope); res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' }); return; } @@ -1141,10 +1146,12 @@ const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): next(); return; } + if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope); res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' }); return; } + if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied: unknown scope', req.method, req.path, 'scope:', scope); res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' }); }; @@ -4848,16 +4855,30 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): return; } - const user = DatabaseService.getInstance().getUserByUsername(req.user!.username); + const db = DatabaseService.getInstance(); + const user = db.getUserByUsername(req.user!.username); if (!user) { res.status(500).json({ error: 'User not found.' }); return; } - const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret); + const activeCount = db.getActiveApiTokenCountByUser(user.id); + if (activeCount >= 25) { + res.status(400).json({ error: 'Maximum of 25 active API tokens per user.' }); + return; + } + + if (db.getActiveApiTokenByNameAndUser(name.trim(), user.id)) { + res.status(409).json({ error: 'An active token with this name already exists.' }); + return; + } + + // JWT ceiling exceeds the longest user-selectable expiry (365d) so the DB check is always tighter + const API_TOKEN_JWT_CEILING = '400d'; + const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret, { expiresIn: API_TOKEN_JWT_CEILING }); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); - const id = DatabaseService.getInstance().addApiToken({ + const id = db.addApiToken({ token_hash: tokenHash, name: name.trim(), scope: scope as 'read-only' | 'deploy-only' | 'full-admin', @@ -4866,6 +4887,7 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): expires_at: expiresAt, }); + if (isDebugEnabled()) console.log('[ApiTokens:diag] Token created:', { name: name.trim(), scope, expires_in, user: req.user!.username }); res.status(201).json({ id, token: rawToken }); } catch (error) { console.error('[ApiTokens] Create error:', error); @@ -4914,6 +4936,7 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp } DatabaseService.getInstance().revokeApiToken(id); + if (isDebugEnabled()) console.log('[ApiTokens:diag] Token revoked:', { id, name: apiToken.name, user: req.user!.username }); res.json({ success: true }); } catch (error) { console.error('[ApiTokens] Revoke error:', error); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 636559cb..0dc483e7 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1407,6 +1407,19 @@ export class DatabaseService { this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id); } + public getActiveApiTokenCountByUser(userId: number): number { + const row = this.db.prepare( + 'SELECT COUNT(*) AS cnt FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL' + ).get(userId) as { cnt: number }; + return row.cnt; + } + + public getActiveApiTokenByNameAndUser(name: string, userId: number): ApiToken | undefined { + return this.db.prepare( + 'SELECT * FROM api_tokens WHERE name = ? AND user_id = ? AND revoked_at IS NULL LIMIT 1' + ).get(name, userId) as ApiToken | undefined; + } + // --- Registries --- public getRegistries(): Registry[] { diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index fd9bb60f..6f830c7d 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -33,6 +33,7 @@ Regardless of scope, **all** API tokens are blocked from: | **Node management** | Adding, updating, or deleting remote nodes | | **License management** | Activating or deactivating license keys | | **Token management** | Creating, listing, or revoking API tokens | +| **Registry management** | Viewing, creating, updating, deleting, or testing registry credentials | | **Console access** | Generating console session tokens for interactive terminals | These restrictions ensure that API tokens cannot escalate privileges or modify the identity and infrastructure configuration of your Sencho instance. These operations require a human user session (browser login). @@ -47,6 +48,10 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t - **Expiration**: Choose 30 days, 60 days, 90 days, 1 year, or no expiration. Tokens without an expiration must be revoked manually. 4. Click **Create**. A green banner appears with the raw token value. Copy it immediately using the copy button. + + Each user can have up to **25 active API tokens**. Token names must be unique among your active tokens. If you need to reuse a name, revoke the existing token first. + + API Tokens management view in Settings Hub showing the token list and create form @@ -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() {

@@ -150,31 +155,36 @@ export function ApiTokensSection() { placeholder="CI deploy pipeline" value={formName} onChange={e => setFormName(e.target.value)} + maxLength={100} />
- +
- + setFormExpiry(v === 'never' ? null : Number(v))} + placeholder="Select expiration..." + />
@@ -195,7 +205,7 @@ export function ApiTokensSection() {
{newToken.token}
@@ -221,7 +231,7 @@ export function ApiTokensSection() { {/* Token list */} {!loading && tokens.map(token => ( -
+
@@ -232,8 +242,8 @@ export function ApiTokensSection() {
- @@ -252,7 +262,7 @@ export function ApiTokensSection() {
-
+
Created {formatDate(token.created_at)}