mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add API tokens management, docs & UI
Introduce full API token support: add a Prisma migration to create api_tokens table and indexes; add types, constants and a generateApiToken utility (hashed token + prefix). Update admin and user token routes to use the generator, enforce per-user active token caps, and integrate rate-limit checks. Add an interactive API docs page with TokenInput, EndpointCard and ResponseViewer components, plus a protected page route. Improve confirmation UX with an accessible ConfirmDialog (focus trap, Escape to close, animations) and wire confirm flows into admin/profile token sections; also update ConfirmModal to accept node messages. Add dialog CSS animations and enhance clipboard error handling. Update related middleware, utils and tests to reflect changes.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: API Token Constants
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* Centralized API token constants used across authentication middleware and token routes.
|
||||
*/
|
||||
|
||||
/** Prefix prepended to all generated API tokens for identification */
|
||||
export const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
/** Number of random bytes used to generate the token's random portion */
|
||||
export const TOKEN_RANDOM_BYTES = 32;
|
||||
|
||||
/** Length of the token prefix stored in the database for display (first 12 chars: "rmab_" + 7 hex chars) */
|
||||
export const TOKEN_PREFIX_LENGTH = 12;
|
||||
|
||||
/** Maximum number of active (non-expired) API tokens a single user may hold */
|
||||
export const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint allowlist — restricts which routes API tokens may access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of an allowed endpoint entry */
|
||||
export interface AllowedEndpoint {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Extended metadata used by the interactive API docs page */
|
||||
export interface EndpointDoc {
|
||||
method: string;
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
requiresAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that API tokens are permitted to call.
|
||||
* JWT-authenticated sessions are NOT restricted by this list.
|
||||
*/
|
||||
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/requests' },
|
||||
{ method: 'GET', path: '/api/admin/metrics' },
|
||||
{ method: 'GET', path: '/api/admin/downloads/active' },
|
||||
{ method: 'GET', path: '/api/admin/requests/recent' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Full documentation metadata for each allowed endpoint.
|
||||
* Consumed by the /api-docs interactive page.
|
||||
*/
|
||||
export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/auth/me',
|
||||
title: 'Get current user',
|
||||
description:
|
||||
'Returns the authenticated user\'s profile information including username, role, and account details.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/requests',
|
||||
title: 'List requests',
|
||||
description:
|
||||
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
|
||||
requiresAdmin: false,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/metrics',
|
||||
title: 'System metrics',
|
||||
description:
|
||||
'Returns system health metrics including request counts, download statistics, and library size.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/downloads/active',
|
||||
title: 'Active downloads',
|
||||
description:
|
||||
'Returns currently active downloads including progress, speed, and ETA.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/admin/requests/recent',
|
||||
title: 'Recent requests',
|
||||
description:
|
||||
'Returns the most recent audiobook requests across all users.',
|
||||
requiresAdmin: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check whether a given method + path is on the API token allowlist.
|
||||
* Method comparison is case-insensitive.
|
||||
*/
|
||||
export function isEndpointAllowed(method: string, path: string): boolean {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,10 @@ import crypto from 'crypto';
|
||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
const API_TOKEN_PREFIX = 'rmab_';
|
||||
|
||||
export interface AuthenticatedRequest extends NextRequest {
|
||||
user?: TokenPayload & { id: string };
|
||||
}
|
||||
@@ -127,6 +126,23 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce endpoint allowlist for API token auth
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const method = request.method;
|
||||
if (!isEndpointAllowed(method, pathname)) {
|
||||
logger.warn('API token used on restricted endpoint', {
|
||||
method,
|
||||
path: pathname,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'This endpoint is not available via API token authentication',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = apiUser;
|
||||
return handler(authenticatedRequest);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: API Token Type Definitions
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
/** Base API token as returned by user-facing endpoints */
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
role: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Extended API token with cross-user fields, returned by admin endpoints */
|
||||
export interface AdminApiToken extends ApiToken {
|
||||
createdBy: string;
|
||||
createdById: string;
|
||||
tokenUser: string;
|
||||
tokenUserId: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: API Token Generation Utility
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { API_TOKEN_PREFIX, TOKEN_RANDOM_BYTES, TOKEN_PREFIX_LENGTH } from '../constants/api-tokens';
|
||||
|
||||
interface GeneratedToken {
|
||||
/** The full token string to return to the user (shown only once) */
|
||||
fullToken: string;
|
||||
/** SHA-256 hash of the full token (stored in database) */
|
||||
tokenHash: string;
|
||||
/** Display prefix for identification (first 12 chars) */
|
||||
tokenPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token with its hash and display prefix.
|
||||
* The full token is: API_TOKEN_PREFIX + random hex string.
|
||||
* Only the hash is stored; the full token is returned once at creation.
|
||||
*/
|
||||
export function generateApiToken(): GeneratedToken {
|
||||
const randomPart = crypto.randomBytes(TOKEN_RANDOM_BYTES).toString('hex');
|
||||
const fullToken = `${API_TOKEN_PREFIX}${randomPart}`;
|
||||
const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex');
|
||||
const tokenPrefix = fullToken.substring(0, TOKEN_PREFIX_LENGTH);
|
||||
|
||||
return { fullToken, tokenHash, tokenPrefix };
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Component: API Token Rate Limiting
|
||||
* Documentation: documentation/backend/services/api-tokens.md
|
||||
*
|
||||
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
|
||||
* to prevent unbounded memory growth.
|
||||
*/
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
@@ -10,11 +18,42 @@ type RateLimitResult = {
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Number of checkRateLimit calls since the last full sweep */
|
||||
let checkCount = 0;
|
||||
|
||||
/** How often (in calls) to perform a full sweep of expired buckets */
|
||||
const SWEEP_INTERVAL = 100;
|
||||
|
||||
/**
|
||||
* Sweep the entire bucket map and delete all expired entries.
|
||||
* Called automatically every SWEEP_INTERVAL checks.
|
||||
*/
|
||||
function sweepExpiredBuckets(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now >= bucket.resetAt) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Periodic full sweep every SWEEP_INTERVAL calls
|
||||
checkCount += 1;
|
||||
if (checkCount >= SWEEP_INTERVAL) {
|
||||
checkCount = 0;
|
||||
sweepExpiredBuckets();
|
||||
}
|
||||
|
||||
const current = buckets.get(key);
|
||||
|
||||
// Lazy eviction: if the bucket is expired, delete it and start fresh
|
||||
if (!current || now >= current.resetAt) {
|
||||
if (current) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||
}
|
||||
@@ -40,3 +79,14 @@ export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||
}
|
||||
|
||||
/** Reset all buckets and the sweep counter. For testing only. */
|
||||
export function _resetBuckets(): void {
|
||||
buckets.clear();
|
||||
checkCount = 0;
|
||||
}
|
||||
|
||||
/** Get the current number of tracked buckets. For testing only. */
|
||||
export function _getBucketCount(): number {
|
||||
return buckets.size;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user