mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
fix(api-tokens): harden with security fixes, design compliance, and test coverage (#567)
Security: add JWT-level expiry ceiling (400d), per-user token count limit (25), and token name uniqueness enforcement. Fix async clipboard copy. Design: migrate Select to Combobox, apply card bevel styling, fix icon strokeWidth, add tabular-nums to timestamps, fix destructive button pattern. Tests: expand from ~20 to 47 test cases covering creation validation, token limits, name uniqueness, last_used_at tracking, ownership constraints, delete edge cases, and registry blocked endpoints. Docs: update API Tokens docs with token limits, name uniqueness, registry restrictions, and JWT expiry ceiling. Update OpenAPI spec with 409 response.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,19 @@ export async function setupTestDb(): Promise<string> {
|
||||
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<string> {
|
||||
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 });
|
||||
|
||||
+26
-3
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/api-tokens/api-tokens-overview.png" alt="API Tokens management view in Settings Hub showing the token list and create form" />
|
||||
</Frame>
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 38 KiB |
+9
-3
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Create Token
|
||||
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} /> Create Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -150,31 +155,36 @@ export function ApiTokensSection() {
|
||||
placeholder="CI deploy pipeline"
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Permission Scope</Label>
|
||||
<Select value={formScope} onValueChange={setFormScope}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read-only">Read Only - GET requests only</SelectItem>
|
||||
<SelectItem value="deploy-only">Deploy Only - read + deploy actions</SelectItem>
|
||||
<SelectItem value="full-admin">Full Admin - unrestricted access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: 'read-only', label: 'Read Only - GET requests only' },
|
||||
{ value: 'deploy-only', label: 'Deploy Only - read + deploy actions' },
|
||||
{ value: 'full-admin', label: 'Full Admin - unrestricted access' },
|
||||
]}
|
||||
value={formScope}
|
||||
onValueChange={setFormScope}
|
||||
placeholder="Select scope..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Expiration</Label>
|
||||
<Select value={formExpiry === null ? 'never' : String(formExpiry)} onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">30 days</SelectItem>
|
||||
<SelectItem value="60">60 days</SelectItem>
|
||||
<SelectItem value="90">90 days</SelectItem>
|
||||
<SelectItem value="365">1 year</SelectItem>
|
||||
<SelectItem value="never">No expiration</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: '30', label: '30 days' },
|
||||
{ value: '60', label: '60 days' },
|
||||
{ value: '90', label: '90 days' },
|
||||
{ value: '365', label: '1 year' },
|
||||
{ value: 'never', label: 'No expiration' },
|
||||
]}
|
||||
value={formExpiry === null ? 'never' : String(formExpiry)}
|
||||
onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}
|
||||
placeholder="Select expiration..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
@@ -195,7 +205,7 @@ export function ApiTokensSection() {
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all select-all">{newToken.token}</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(newToken.token, 'Token')}>
|
||||
<Copy className="w-4 h-4" />
|
||||
<Copy className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setNewToken(null)}>Dismiss</Button>
|
||||
@@ -221,7 +231,7 @@ export function ApiTokensSection() {
|
||||
|
||||
{/* Token list */}
|
||||
{!loading && tokens.map(token => (
|
||||
<div key={token.id} className="border border-border rounded-xl p-4 space-y-3">
|
||||
<div key={token.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Zap className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
@@ -232,8 +242,8 @@ export function ApiTokensSection() {
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive shrink-0">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0">
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -252,7 +262,7 @@ export function ApiTokensSection() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Created {formatDate(token.created_at)}
|
||||
|
||||
Reference in New Issue
Block a user