Add API token allowlist, docs, UI and tests

Introduce API token allowlist support and documentation. Adds a new backend docs page for API tokens and updates TABLEOFCONTENTS. Implements API token constants and a compiled matcher (isEndpointAllowed) with support for single-segment :placeholders and an isWrite flag. Split getCurrentUser into a JWT-only helper and added getCurrentUserAsync to recognize rmab_ API tokens; updated the audiobooks search route to use getCurrentUserAsync. Update API docs UI (EndpointCard and api-docs page) to surface Write badges and disable "Try it" for mutating endpoints, and add a profile warning in ApiTokensSection. Add tests for the allowlist matcher and middleware, and adjust existing route tests/mocks accordingly.
This commit is contained in:
kikootwo
2026-05-16 14:17:49 -04:00
parent e39e44ee44
commit 6ec53ff7e3
11 changed files with 417 additions and 39 deletions
+1
View File
@@ -6,6 +6,7 @@
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) - **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **API tokens (allowlist, write capability, /api-docs)** → [backend/services/api-tokens.md](backend/services/api-tokens.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md) - **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
@@ -0,0 +1,84 @@
# API Tokens
**Status:** ✅ Implemented | Personal long-lived tokens, allowlisted endpoints, write capability per issue #169
## Overview
Static `rmab_`-prefixed tokens act with the owner's full user-level permissions on a fixed allowlist of endpoints. JWT sessions are NOT restricted by the allowlist.
## Key Details
- **Prefix:** `rmab_` (12-char stored display prefix: `rmab_` + 7 hex chars)
- **Storage:** SHA-256 hash in `apiToken.tokenHash`; full token shown ONCE on create
- **Role binding:** Token `role` matches token owner's role at creation time; admin tokens require admin-created
- **Per-user cap:** 25 active (non-expired) tokens (`MAX_TOKENS_PER_USER`)
- **Expiry:** Optional (`never`, `30d`, `90d`, `1y`)
- **Soft-deleted users:** Tokens reject if `tokenUser.deletedAt` is set
- **Identity attribution:** `req.user.id` resolves to `apiToken.userId` (target user), NOT `apiToken.createdById`
- **Header:** `Authorization: Bearer rmab_<token>`
## Allowed Endpoints
| Method | Path | Title | Write | Admin |
|---|---|---|---|---|
| GET | `/api/auth/me` | Current user | | |
| GET | `/api/audiobooks/search` | Search audiobooks | | |
| GET | `/api/requests` | List requests | | |
| POST | `/api/requests` | Create request | ✓ | |
| GET | `/api/requests/:id` | Get request by ID | | |
| GET | `/api/admin/metrics` | System metrics | | ✓ |
| GET | `/api/admin/downloads/active` | Active downloads | | ✓ |
| GET | `/api/admin/requests/recent` | Recent requests | | ✓ |
Source of truth: `src/lib/constants/api-tokens.ts` (`API_TOKEN_ALLOWED_ENDPOINTS`, `API_TOKEN_ENDPOINT_DOCS`).
## Matcher (`isEndpointAllowed`)
- Compiled once at module load.
- `path` entries containing `:name` are converted to anchored regexes where each placeholder matches `[^/]+` (a single segment).
- Sibling sub-routes (e.g. `/api/requests/:id/select-torrent`) are NOT matched by the `/api/requests/:id` entry — they require their own allowlist entry.
- Method comparison is case-insensitive.
## POST `/api/requests` (Write)
- Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`
- Internally calls `createRequestForUser(req.user.id, audiobook, { bypassIgnore: true })` — token requests bypass the ignore list, matching UI behavior.
- Optional query param: `?skipAutoSearch=true` defers search-job creation.
- Side effects (identical to UI): duplicate detection, library check, Audnexus enrichment, audiobook upsert, ignore-list check (bypassed), per-user dedup, auto-approve gating, release-date gate, notification queue, search-job queue.
- Auto-approve: follows the token owner's per-user `autoApproveRequests` setting, then global. No bypass.
- Response: `201 { success: true, request }` or named error: `{ error: "AlreadyAvailable" | "BeingProcessed" | "DuplicateRequest" | "Ignored" | "UserNotFound" | "ValidationError", message }`
## GET `/api/requests/:id`
- Returns full request including `audiobook`, `downloadHistory` (selected), and recent `jobs`.
- Ownership enforced: `requestRecord.userId === req.user.id || role === 'admin'` → otherwise 403.
- Soft-deleted requests (`deletedAt != null`) return 404.
## GET `/api/audiobooks/search`
- Auth is optional, NOT gated by allowlist (route never calls `requireAuth`).
- Uses `getCurrentUserAsync` to recognize both JWT sessions AND API tokens for per-user enrichment (request status, ignore status).
- Without auth: returns generic results with no user-context annotations.
- With JWT or `rmab_` token: returns results enriched with `isRequested`, `requestStatus`, `requestId`, `isIgnored`, etc.
## Auth flow
1. Request hits route; `requireAuth` extracts `Authorization: Bearer ...` token.
2. If token starts with `rmab_``authenticateApiToken` (SHA-256 lookup, expiry + soft-delete check, fire-and-forget `lastUsedAt` update).
3. If on the allowlist → handler runs with `req.user = { sub, id, plexId, username, role }`.
4. If not on the allowlist → 403 "This endpoint is not available via API token authentication".
5. JWT tokens skip the allowlist entirely.
## UI surfaces
- `/api-docs` page (`src/app/api-docs/page.tsx`) — auto-renders `API_TOKEN_ENDPOINT_DOCS`. Endpoints with `isWrite: true` show an amber **Write** badge; the "Try it" button is disabled with a "use curl" hint to avoid sending mutating requests from a UI that cannot construct request bodies.
- Profile → API Tokens (`src/components/profile/ApiTokensSection.tsx`) — create/revoke UI. Includes a one-line warning that tokens act with the owner's full permissions.
- Admin → Users → API Tokens — admin can create tokens on behalf of any user.
## Files
- Constants + matcher: `src/lib/constants/api-tokens.ts`
- Middleware: `src/lib/middleware/auth.ts` (`requireAuth`, `getCurrentUser`, `getCurrentUserAsync`)
- Routes:
- `src/app/api/user/api-tokens/route.ts` (user create/list/revoke)
- `src/app/api/admin/api-tokens/route.ts` (admin)
- UI: `src/app/api-docs/page.tsx`, `src/components/api-docs/EndpointCard.tsx`, `src/components/api-docs/TokenInput.tsx`, `src/components/profile/ApiTokensSection.tsx`
## Tests
- `tests/constants/api-tokens.test.ts` — matcher: positive matches, negative matches, sub-route exclusion, method case-insensitivity, allowlist/docs parity.
- `tests/middleware/auth.middleware.test.ts` — middleware token auth path, allowlist enforcement (incl. dynamic ID match), sibling-route blocking, `getCurrentUserAsync`.
- `tests/api/requests-id.route.test.ts` — owner GET 200, cross-user GET 403.
## Related
- [backend/services/auth.md](auth.md) — JWT sessions, role-based access control
- [backend/services/notifications.md](notifications.md) — request notification triggers
+3 -1
View File
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */} {/* Footer note */}
<div className="mt-10 text-center"> <div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500"> <p className="text-xs text-gray-400 dark:text-gray-500">
API tokens are restricted to the endpoints listed above. API tokens are restricted to the endpoints listed above. Endpoints
flagged <span className="font-semibold text-amber-600 dark:text-amber-400">Write</span> mutate
state on behalf of the token owner keep your tokens private.
JWT session authentication has access to all endpoints. JWT session authentication has access to all endpoints.
</p> </p>
</div> </div>
+3 -3
View File
@@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service'; import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUserAsync } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const results = await audibleService.search(query, page); const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment) // Get current user (optional — JWT or API token — for request-status enrichment)
const currentUser = getCurrentUser(request); const currentUser = await getCurrentUserAsync(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
// Two-pass dedup: local title/narrator/duration matching first, then collapse // Two-pass dedup: local title/narrator/duration matching first, then collapse
+31 -18
View File
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin Admin
</span> </span>
)} )}
{endpoint.isWrite && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
Write
</span>
)}
</div> </div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1"> <h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
{endpoint.title} {endpoint.title}
@@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</p> </p>
</div> </div>
<button <div className="flex-shrink-0 flex flex-col items-end gap-1">
onClick={handleTryIt} <button
disabled={loading} onClick={handleTryIt}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]" disabled={loading || endpoint.isWrite}
> title={endpoint.isWrite ? 'Use curl or your API client — write actions disabled here for safety' : undefined}
{loading ? ( className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.97]"
<> >
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" /> {loading ? (
Running <>
</> <div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
) : ( Running
<> </>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /> <>
</svg> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Try it <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</> </svg>
Try it
</>
)}
</button>
{endpoint.isWrite && (
<span className="text-[11px] text-gray-500 dark:text-gray-400 max-w-[180px] text-right leading-snug">
Use curl or your API client write actions disabled here for safety.
</span>
)} )}
</button> </div>
</div> </div>
{/* Expandable response area */} {/* Expandable response area */}
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
View API documentation View API documentation
</Link> </Link>
</p> </p>
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
API tokens act with your full user-level permissions, including creating audiobook
requests on your behalf. Keep them private.
</p>
</div> </div>
</div> </div>
+65 -5
View File
@@ -21,7 +21,11 @@ export const MAX_TOKENS_PER_USER = 25;
// Endpoint allowlist — restricts which routes API tokens may access // Endpoint allowlist — restricts which routes API tokens may access
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Shape of an allowed endpoint entry */ /**
* Shape of an allowed endpoint entry.
* `path` may be a literal (e.g. `/api/requests`) or contain `:name` placeholders
* that match a single path segment (e.g. `/api/requests/:id`).
*/
export interface AllowedEndpoint { export interface AllowedEndpoint {
method: string; method: string;
path: string; path: string;
@@ -34,6 +38,8 @@ export interface EndpointDoc {
title: string; title: string;
description: string; description: string;
requiresAdmin: boolean; requiresAdmin: boolean;
/** True for endpoints that mutate state. Surfaced in the /api-docs UI. */
isWrite?: boolean;
} }
/** /**
@@ -42,7 +48,10 @@ export interface EndpointDoc {
*/ */
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [ export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
{ method: 'GET', path: '/api/auth/me' }, { method: 'GET', path: '/api/auth/me' },
{ method: 'GET', path: '/api/audiobooks/search' },
{ method: 'GET', path: '/api/requests' }, { method: 'GET', path: '/api/requests' },
{ method: 'POST', path: '/api/requests' },
{ method: 'GET', path: '/api/requests/:id' },
{ method: 'GET', path: '/api/admin/metrics' }, { method: 'GET', path: '/api/admin/metrics' },
{ method: 'GET', path: '/api/admin/downloads/active' }, { method: 'GET', path: '/api/admin/downloads/active' },
{ method: 'GET', path: '/api/admin/requests/recent' }, { method: 'GET', path: '/api/admin/requests/recent' },
@@ -61,6 +70,14 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
'Returns the authenticated user\'s profile information including username, role, and account details.', 'Returns the authenticated user\'s profile information including username, role, and account details.',
requiresAdmin: false, requiresAdmin: false,
}, },
{
method: 'GET',
path: '/api/audiobooks/search',
title: 'Search audiobooks',
description:
'Search Audible for audiobooks by title or author. Query params: `q` (required), `page` (optional). Returns enriched results including per-user request and library availability status.',
requiresAdmin: false,
},
{ {
method: 'GET', method: 'GET',
path: '/api/requests', path: '/api/requests',
@@ -69,6 +86,23 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.', 'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
requiresAdmin: false, requiresAdmin: false,
}, },
{
method: 'POST',
path: '/api/requests',
title: 'Create request',
description:
'Create a new audiobook request on behalf of the token owner. Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`. Follows the user\'s normal auto-approve rules; returns named error codes (`already_available`, `being_processed`, `duplicate`, `ignored`, `user_not_found`) on rejection.',
requiresAdmin: false,
isWrite: true,
},
{
method: 'GET',
path: '/api/requests/:id',
title: 'Get request by ID',
description:
'Returns a single audiobook request including audiobook details, download history, and recent job state. Users may only fetch requests they own; admins may fetch any.',
requiresAdmin: false,
},
{ {
method: 'GET', method: 'GET',
path: '/api/admin/metrics', path: '/api/admin/metrics',
@@ -95,13 +129,39 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
}, },
] as const; ] as const;
/**
* Compiled allowlist used by `isEndpointAllowed`. Patterns with `:name`
* placeholders are compiled to anchored regexes that match a single path
* segment (`[^/]+`); literal paths use string equality.
*/
interface CompiledEndpoint {
method: string;
literal: string | null;
pattern: RegExp | null;
}
function compileEndpoint(ep: AllowedEndpoint): CompiledEndpoint {
const method = ep.method.toUpperCase();
if (!ep.path.includes(':')) {
return { method, literal: ep.path, pattern: null };
}
const escaped = ep.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexSource = escaped.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+');
return { method, literal: null, pattern: new RegExp(`^${regexSource}$`) };
}
const COMPILED_ENDPOINTS: readonly CompiledEndpoint[] = API_TOKEN_ALLOWED_ENDPOINTS.map(compileEndpoint);
/** /**
* Check whether a given method + path is on the API token allowlist. * Check whether a given method + path is on the API token allowlist.
* Method comparison is case-insensitive. * Method comparison is case-insensitive. Supports dynamic single-segment
* placeholders (`:id`) compiled at module load.
*/ */
export function isEndpointAllowed(method: string, path: string): boolean { export function isEndpointAllowed(method: string, path: string): boolean {
const upperMethod = method.toUpperCase(); const upperMethod = method.toUpperCase();
return API_TOKEN_ALLOWED_ENDPOINTS.some( return COMPILED_ENDPOINTS.some((ep) => {
(ep) => ep.method === upperMethod && ep.path === path if (ep.method !== upperMethod) return false;
); if (ep.literal !== null) return ep.literal === path;
return ep.pattern!.test(path);
});
} }
+20 -1
View File
@@ -242,7 +242,9 @@ export async function requireAdmin(
} }
/** /**
* Helper: Get current user from request (for use in API routes) * Helper: Get current user from request (for use in API routes).
* JWT-only — does NOT recognize API tokens. Use `getCurrentUserAsync` if
* the caller should also accept `rmab_`-prefixed API tokens.
*/ */
export function getCurrentUser(request: NextRequest): TokenPayload | null { export function getCurrentUser(request: NextRequest): TokenPayload | null {
const token = extractToken(request); const token = extractToken(request);
@@ -250,6 +252,23 @@ export function getCurrentUser(request: NextRequest): TokenPayload | null {
return verifyAccessToken(token); return verifyAccessToken(token);
} }
/**
* Helper: Get current user from request, recognizing BOTH JWT sessions and
* API tokens (`rmab_` prefix). Returns the same `TokenPayload` shape in both
* cases so callers don't need to branch on auth type.
*
* Use this in routes that are open to optional auth but should still enrich
* responses with per-user context when called by an API token holder.
*/
export async function getCurrentUserAsync(request: NextRequest): Promise<TokenPayload | null> {
const token = extractToken(request);
if (!token) return null;
if (token.startsWith(API_TOKEN_PREFIX)) {
return authenticateApiToken(token);
}
return verifyAccessToken(token);
}
/** /**
* Helper: Check if user is admin * Helper: Check if user is admin
*/ */
@@ -36,6 +36,7 @@ vi.mock('@/lib/utils/ignored-audiobooks', () => ({
vi.mock('@/lib/middleware/auth', () => ({ vi.mock('@/lib/middleware/auth', () => ({
getCurrentUser: currentUserMock, getCurrentUser: currentUserMock,
getCurrentUserAsync: currentUserMock,
})); }));
describe('Audiobooks browse routes', () => { describe('Audiobooks browse routes', () => {
+95
View File
@@ -0,0 +1,95 @@
/**
* Component: API Token Constants Tests
* Documentation: documentation/backend/services/api-tokens.md
*/
import { describe, expect, it } from 'vitest';
import {
API_TOKEN_ALLOWED_ENDPOINTS,
API_TOKEN_ENDPOINT_DOCS,
isEndpointAllowed,
} from '@/lib/constants/api-tokens';
describe('isEndpointAllowed', () => {
describe('positive matches (every allowlisted endpoint)', () => {
const cases: Array<[string, string]> = [
['GET', '/api/auth/me'],
['GET', '/api/audiobooks/search'],
['GET', '/api/requests'],
['POST', '/api/requests'],
['GET', '/api/requests/abc-uuid-123'],
['GET', '/api/requests/00000000-0000-0000-0000-000000000000'],
['GET', '/api/admin/metrics'],
['GET', '/api/admin/downloads/active'],
['GET', '/api/admin/requests/recent'],
];
it.each(cases)('%s %s is allowed', (method, path) => {
expect(isEndpointAllowed(method, path)).toBe(true);
});
});
describe('negative matches', () => {
it('rejects unrelated paths', () => {
expect(isEndpointAllowed('GET', '/api/admin/settings')).toBe(false);
expect(isEndpointAllowed('GET', '/api/audiobooks/popular')).toBe(false);
});
it('rejects wrong HTTP method', () => {
expect(isEndpointAllowed('DELETE', '/api/requests')).toBe(false);
expect(isEndpointAllowed('POST', '/api/requests/abc')).toBe(false);
expect(isEndpointAllowed('PATCH', '/api/requests/abc')).toBe(false);
});
it('rejects sibling sub-routes of /api/requests/:id', () => {
// The :id placeholder must match a SINGLE segment — anything deeper is excluded.
expect(isEndpointAllowed('GET', '/api/requests/abc/select-torrent')).toBe(false);
expect(isEndpointAllowed('GET', '/api/requests/abc/download-token')).toBe(false);
expect(isEndpointAllowed('GET', '/api/requests/abc/interactive-search')).toBe(false);
expect(isEndpointAllowed('GET', '/api/requests/abc/manual-search')).toBe(false);
expect(isEndpointAllowed('GET', '/api/requests/abc/select-ebook')).toBe(false);
expect(isEndpointAllowed('POST', '/api/requests/abc/select-torrent')).toBe(false);
});
it('rejects partial / extended paths', () => {
expect(isEndpointAllowed('GET', '/api/request')).toBe(false);
expect(isEndpointAllowed('GET', '/api/requests/')).toBe(false);
expect(isEndpointAllowed('GET', '/api/auth/me/extra')).toBe(false);
});
it('does not allow empty :id segment to match', () => {
// `/api/requests/:id` requires at least one char in the segment;
// a literal `/api/requests` is matched separately.
expect(isEndpointAllowed('GET', '/api/requests/')).toBe(false);
});
});
describe('method case-insensitivity', () => {
it('accepts lowercase and mixed-case methods', () => {
expect(isEndpointAllowed('get', '/api/requests')).toBe(true);
expect(isEndpointAllowed('Get', '/api/requests')).toBe(true);
expect(isEndpointAllowed('post', '/api/requests')).toBe(true);
expect(isEndpointAllowed('PoSt', '/api/requests')).toBe(true);
});
});
describe('docs / allowlist parity', () => {
it('every documented endpoint is on the allowlist', () => {
for (const doc of API_TOKEN_ENDPOINT_DOCS) {
const found = API_TOKEN_ALLOWED_ENDPOINTS.some(
(ep) => ep.method === doc.method && ep.path === doc.path
);
expect(found, `${doc.method} ${doc.path} missing from allowlist`).toBe(true);
}
});
it('every allowlisted endpoint has a docs entry', () => {
for (const ep of API_TOKEN_ALLOWED_ENDPOINTS) {
const found = API_TOKEN_ENDPOINT_DOCS.some(
(doc) => doc.method === ep.method && doc.path === ep.path
);
expect(found, `${ep.method} ${ep.path} missing from docs`).toBe(true);
}
});
});
});
+110 -11
View File
@@ -290,16 +290,20 @@ describe('auth middleware', () => {
expect(payload.message).toMatch(/not available via API token/i); expect(payload.message).toMatch(/not available via API token/i);
}); });
it('allows API tokens on all 5 permitted endpoints', async () => { it('allows API tokens on every permitted endpoint (incl. dynamic ID match)', async () => {
const allowedPaths = [ const allowedCalls: Array<[string, string]> = [
'/api/auth/me', ['GET', '/api/auth/me'],
'/api/requests', ['GET', '/api/audiobooks/search'],
'/api/admin/metrics', ['GET', '/api/requests'],
'/api/admin/downloads/active', ['POST', '/api/requests'],
'/api/admin/requests/recent', ['GET', '/api/requests/abc-uuid-123'],
['GET', '/api/requests/00000000-0000-0000-0000-000000000000'],
['GET', '/api/admin/metrics'],
['GET', '/api/admin/downloads/active'],
['GET', '/api/admin/requests/recent'],
]; ];
for (const path of allowedPaths) { for (const [method, path] of allowedCalls) {
vi.clearAllMocks(); vi.clearAllMocks();
prismaMock.apiToken.findUnique.mockResolvedValue({ prismaMock.apiToken.findUnique.mockResolvedValue({
id: 'token-1', id: 'token-1',
@@ -318,12 +322,49 @@ describe('auth middleware', () => {
const handler = vi.fn(async () => NextResponse.json({ ok: true })); const handler = vi.fn(async () => NextResponse.json({ ok: true }));
const response = await requireAuth( const response = await requireAuth(
makeRequest(`Bearer ${testToken}`, path, 'GET') as any, makeRequest(`Bearer ${testToken}`, path, method) as any,
handler handler
); );
expect(handler).toHaveBeenCalled(); expect(handler, `${method} ${path}`).toHaveBeenCalled();
expect(response.status).toBe(200); expect(response.status, `${method} ${path}`).toBe(200);
}
});
it('blocks API tokens on sibling sub-routes of /api/requests/:id', async () => {
const blockedCalls: Array<[string, string]> = [
['GET', '/api/requests/abc/select-torrent'],
['GET', '/api/requests/abc/download-token'],
['GET', '/api/requests/abc/interactive-search'],
['POST', '/api/requests/abc/manual-search'],
['POST', '/api/requests/abc/select-ebook'],
];
for (const [method, path] of blockedCalls) {
vi.clearAllMocks();
prismaMock.apiToken.findUnique.mockResolvedValue({
id: 'token-1',
tokenHash: testTokenHash,
role: 'admin',
expiresAt: null,
tokenUser: {
id: 'user-1',
plexUsername: 'activeuser',
role: 'admin',
deletedAt: null,
},
});
prismaMock.apiToken.update.mockResolvedValue({});
const { requireAuth } = await import('@/lib/middleware/auth');
const handler = vi.fn();
const response = await requireAuth(
makeRequest(`Bearer ${testToken}`, path, method) as any,
handler
);
expect(handler, `${method} ${path} should be blocked`).not.toHaveBeenCalled();
expect(response.status, `${method} ${path}`).toBe(403);
} }
}); });
@@ -366,4 +407,62 @@ describe('auth middleware', () => {
expect(payload?.sub).toBe('user-1'); expect(payload?.sub).toBe('user-1');
expect(isAdmin(payload)).toBe(true); expect(isAdmin(payload)).toBe(true);
}); });
describe('getCurrentUserAsync', () => {
it('returns null when no token is present', async () => {
const { getCurrentUserAsync } = await import('@/lib/middleware/auth');
const payload = await getCurrentUserAsync(makeRequest() as any);
expect(payload).toBeNull();
});
it('resolves JWT tokens via verifyAccessToken', async () => {
verifyAccessTokenMock.mockReturnValue({
sub: 'user-jwt',
plexId: 'plex-jwt',
username: 'jwtuser',
role: 'user',
iat: 1,
exp: 2,
});
const { getCurrentUserAsync } = await import('@/lib/middleware/auth');
const payload = await getCurrentUserAsync(makeRequest('Bearer jwttoken') as any);
expect(payload?.sub).toBe('user-jwt');
expect(payload?.role).toBe('user');
});
it('resolves API tokens via apiToken lookup', async () => {
const apiTok = 'rmab_async_test_1234567890';
const apiTokHash = crypto.createHash('sha256').update(apiTok).digest('hex');
prismaMock.apiToken.findUnique.mockResolvedValue({
id: 'token-async',
tokenHash: apiTokHash,
role: 'user',
expiresAt: null,
tokenUser: {
id: 'user-api',
plexId: 'plex-api',
plexUsername: 'apiuser',
role: 'user',
deletedAt: null,
},
});
prismaMock.apiToken.update.mockResolvedValue({});
const { getCurrentUserAsync } = await import('@/lib/middleware/auth');
const payload = await getCurrentUserAsync(makeRequest(`Bearer ${apiTok}`) as any);
expect(payload?.sub).toBe('user-api');
expect(payload?.username).toBe('apiuser');
});
it('returns null for invalid API tokens', async () => {
prismaMock.apiToken.findUnique.mockResolvedValue(null);
const { getCurrentUserAsync } = await import('@/lib/middleware/auth');
const payload = await getCurrentUserAsync(
makeRequest('Bearer rmab_invalid_token_abc') as any
);
expect(payload).toBeNull();
});
});
}); });