mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -6,6 +6,7 @@
|
||||
- **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)
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
@@ -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
|
||||
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
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 { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
|
||||
const audibleService = getAudibleService();
|
||||
const results = await audibleService.search(query, page);
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
// Get current user (optional — JWT or API token — for request-status enrichment)
|
||||
const currentUser = await getCurrentUserAsync(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
|
||||
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
|
||||
Admin
|
||||
</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>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{endpoint.title}
|
||||
@@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTryIt}
|
||||
disabled={loading}
|
||||
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]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
Try it
|
||||
</>
|
||||
<div className="flex-shrink-0 flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleTryIt}
|
||||
disabled={loading || endpoint.isWrite}
|
||||
title={endpoint.isWrite ? 'Use curl or your API client — write actions disabled here for safety' : undefined}
|
||||
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]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
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>
|
||||
|
||||
{/* Expandable response area */}
|
||||
|
||||
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
|
||||
View API documentation
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ export const MAX_TOKENS_PER_USER = 25;
|
||||
// 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 {
|
||||
method: string;
|
||||
path: string;
|
||||
@@ -34,6 +38,8 @@ export interface EndpointDoc {
|
||||
title: string;
|
||||
description: string;
|
||||
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[] = [
|
||||
{ method: 'GET', path: '/api/auth/me' },
|
||||
{ method: 'GET', path: '/api/audiobooks/search' },
|
||||
{ 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/downloads/active' },
|
||||
{ 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.',
|
||||
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',
|
||||
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.',
|
||||
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',
|
||||
path: '/api/admin/metrics',
|
||||
@@ -95,13 +129,39 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
|
||||
},
|
||||
] 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.
|
||||
* 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 {
|
||||
const upperMethod = method.toUpperCase();
|
||||
return API_TOKEN_ALLOWED_ENDPOINTS.some(
|
||||
(ep) => ep.method === upperMethod && ep.path === path
|
||||
);
|
||||
return COMPILED_ENDPOINTS.some((ep) => {
|
||||
if (ep.method !== upperMethod) return false;
|
||||
if (ep.literal !== null) return ep.literal === path;
|
||||
return ep.pattern!.test(path);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
const token = extractToken(request);
|
||||
@@ -250,6 +252,23 @@ export function getCurrentUser(request: NextRequest): TokenPayload | null {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock('@/lib/utils/ignored-audiobooks', () => ({
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
getCurrentUser: currentUserMock,
|
||||
getCurrentUserAsync: currentUserMock,
|
||||
}));
|
||||
|
||||
describe('Audiobooks browse routes', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -290,16 +290,20 @@ describe('auth middleware', () => {
|
||||
expect(payload.message).toMatch(/not available via API token/i);
|
||||
});
|
||||
|
||||
it('allows API tokens on all 5 permitted endpoints', async () => {
|
||||
const allowedPaths = [
|
||||
'/api/auth/me',
|
||||
'/api/requests',
|
||||
'/api/admin/metrics',
|
||||
'/api/admin/downloads/active',
|
||||
'/api/admin/requests/recent',
|
||||
it('allows API tokens on every permitted endpoint (incl. dynamic ID match)', async () => {
|
||||
const allowedCalls: 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'],
|
||||
];
|
||||
|
||||
for (const path of allowedPaths) {
|
||||
for (const [method, path] of allowedCalls) {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||
id: 'token-1',
|
||||
@@ -318,12 +322,49 @@ describe('auth middleware', () => {
|
||||
|
||||
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||
const response = await requireAuth(
|
||||
makeRequest(`Bearer ${testToken}`, path, 'GET') as any,
|
||||
makeRequest(`Bearer ${testToken}`, path, method) as any,
|
||||
handler
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler, `${method} ${path}`).toHaveBeenCalled();
|
||||
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(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user