diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a39100..6e4f251 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model User { goodreadsShelves GoodreadsShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") + createdApiTokens ApiToken[] @relation("CreatedApiTokens") + apiTokens ApiToken[] @relation("UserApiTokens") @@index([plexId]) @@index([role]) @@ -496,6 +498,34 @@ model ReportedIssue { // Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache // ============================================================================ +// ============================================================================ +// API TOKEN TABLE +// Static API tokens for programmatic access (alternative to JWT) +// Documentation: documentation/backend/services/api-tokens.md +// ============================================================================ + +model ApiToken { + id String @id @default(uuid()) + name String // User-friendly label (e.g., "Home Assistant", "Webhook") + tokenHash String @unique @map("token_hash") // SHA-256 hash of the token (never store plaintext) + tokenPrefix String @map("token_prefix") // First 8 chars for display (e.g., "rmab_a1b2") + role String @default("user") // Token role: 'admin' or 'user' + createdById String @map("created_by_id") // Who created the token (may differ from userId for admin-created tokens) + userId String @map("user_id") // The user identity this token acts as + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") // null = never expires + createdAt DateTime @default(now()) @map("created_at") + + // Relations + createdBy User @relation("CreatedApiTokens", fields: [createdById], references: [id], onDelete: Cascade) + tokenUser User @relation("UserApiTokens", fields: [userId], references: [id], onDelete: Cascade) + + @@index([tokenHash]) + @@index([createdById]) + @@index([userId]) + @@map("api_tokens") +} + model GoodreadsShelf { id String @id @default(uuid()) userId String @map("user_id") diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts index ff8f648..38271c5 100644 --- a/src/app/admin/settings/lib/helpers.ts +++ b/src/app/admin/settings/lib/helpers.ts @@ -210,6 +210,7 @@ export const getTabValidation = ( return validated.paths; case 'ebook': case 'bookdate': + case 'api': return true; // These tabs handle their own saving default: return false; @@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [ { id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' }, { id: 'bookdate' as const, label: 'BookDate', icon: '📚' }, { id: 'notifications' as const, label: 'Notifications', icon: '🔔' }, + { id: 'api' as const, label: 'API', icon: '🔑' }, ]; diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 6a104cf..bbb4070 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -243,4 +243,4 @@ export interface BookDateModel { /** * Tab identifier type */ -export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications'; +export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications' | 'api'; diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 01b5416..13af2e1 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab'; import { EbookTab } from './tabs/EbookTab/EbookTab'; import { BookDateTab } from './tabs/BookDateTab/BookDateTab'; import { NotificationsTab } from './tabs/NotificationsTab'; +import { ApiTab } from './tabs/ApiTab/ApiTab'; // Types and Helpers import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types'; @@ -346,8 +347,11 @@ export default function AdminSettings() { {/* Notifications Tab */} {activeTab === 'notifications' && } + {/* API Tab */} + {activeTab === 'api' && } + {/* Save Button (only for tabs that save through main page) */} - {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && ( + {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
+
+ + + + + )} + + {/* Create token form */} + {showCreateForm ? ( +
+

Create New Token

+
+
+ + setNewTokenName(e.target.value)} + placeholder="e.g., Home Assistant, Webhook" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Token list */} + {tokens.length === 0 ? ( +
+ + + +

No API tokens yet

+

Create a token to enable programmatic API access

+
+ ) : ( +
+ + + + + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + + + + ))} + +
NameTokenActs AsRoleCreated ByLast UsedExpiresActions
{token.name} + + {token.tokenPrefix}... + + {token.tokenUser} + + {token.role} + + {token.createdBy}{formatDate(token.lastUsedAt)} + {token.expiresAt ? ( + + {formatDate(token.expiresAt)} + {new Date(token.expiresAt) < new Date() && ' (expired)'} + + ) : ( + 'Never' + )} + + +
+
+ )} + + {/* Usage instructions */} +
+

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+        
+
+ + ); +} diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts new file mode 100644 index 0000000..cfe7b38 --- /dev/null +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -0,0 +1,42 @@ +/** + * Component: API Token Delete Route + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.ApiTokens'); + +/** + * DELETE /api/admin/api-tokens/[id] + * Revoke (delete) an API token + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const { id } = await params; + + const token = await prisma.apiToken.findUnique({ where: { id } }); + if (!token) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + await prisma.apiToken.delete({ where: { id } }); + + logger.info('API token revoked', { tokenId: id, name: token.name, revokedBy: req.user!.username }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke API token', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 }); + } + }) + ); +} diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts new file mode 100644 index 0000000..34bf58f --- /dev/null +++ b/src/app/api/admin/api-tokens/route.ts @@ -0,0 +1,145 @@ +/** + * Component: Admin API Token Management Routes + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.Admin.ApiTokens'); + +const API_TOKEN_PREFIX = 'rmab_'; +const TOKEN_RANDOM_BYTES = 32; + +const CreateTokenSchema = z.object({ + name: z.string().min(1).max(100), + expiresAt: z.string().datetime().nullable().optional(), + userId: z.string().uuid().optional(), // Admin can specify which user the token acts as + role: z.enum(['admin', 'user']).optional(), // Admin can override the token role +}); + +/** + * GET /api/admin/api-tokens + * List ALL API tokens across all users + */ +export async function GET(request: NextRequest) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const tokens = await prisma.apiToken.findMany({ + include: { + createdBy: { + select: { id: true, plexUsername: true }, + }, + tokenUser: { + select: { id: true, plexUsername: true, role: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const sanitized = tokens.map((t) => ({ + id: t.id, + name: t.name, + tokenPrefix: t.tokenPrefix, + role: t.role, + createdBy: t.createdBy.plexUsername, + createdById: t.createdBy.id, + tokenUser: t.tokenUser.plexUsername, + tokenUserId: t.tokenUser.id, + lastUsedAt: t.lastUsedAt, + expiresAt: t.expiresAt, + createdAt: t.createdAt, + })); + + return NextResponse.json({ tokens: sanitized }); + } catch (error) { + logger.error('Failed to list API tokens', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 }); + } + }) + ); +} + +/** + * POST /api/admin/api-tokens + * Create a new API token. Admin can optionally specify userId and role. + * Returns the full token ONCE. + */ +export async function POST(request: NextRequest) { + return requireAuth(request, (req: AuthenticatedRequest) => + requireAdmin(req, async () => { + try { + const body = await req.json(); + const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body); + + // Determine target user (defaults to the admin themselves) + const targetUserId = userId || req.user!.id; + + // Verify the target user exists + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { id: true, role: true, plexUsername: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'Target user not found' }, { status: 404 }); + } + + // Determine token role (defaults to target user's role) + const tokenRole = role || targetUser.role; + + // Generate the token + 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, 12); // "rmab_" + 7 chars + + const apiToken = await prisma.apiToken.create({ + data: { + name, + tokenHash, + tokenPrefix, + role: tokenRole, + createdById: req.user!.id, + userId: targetUserId, + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }); + + logger.info('Admin API token created', { + tokenId: apiToken.id, + name, + createdBy: req.user!.username, + targetUser: targetUser.plexUsername, + role: tokenRole, + }); + + return NextResponse.json({ + token: { + id: apiToken.id, + name: apiToken.name, + tokenPrefix: apiToken.tokenPrefix, + role: apiToken.role, + expiresAt: apiToken.expiresAt, + createdAt: apiToken.createdAt, + }, + // Full token is returned ONLY on creation + fullToken, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to create API token', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 }); + } + }) + ); +} diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts new file mode 100644 index 0000000..771113c --- /dev/null +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -0,0 +1,45 @@ +/** + * Component: User API Token Delete Route (self-service) + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.User.ApiTokens'); + +/** + * DELETE /api/user/api-tokens/[id] + * Revoke (delete) one of the current user's own API tokens + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const { id } = await params; + + const token = await prisma.apiToken.findUnique({ where: { id } }); + if (!token) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + // Only allow deleting own tokens + if (token.userId !== req.user!.id) { + return NextResponse.json({ error: 'Token not found' }, { status: 404 }); + } + + await prisma.apiToken.delete({ where: { id } }); + + logger.info('User API token revoked', { tokenId: id, name: token.name, userId: req.user!.id }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke user API token', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to revoke API token' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts new file mode 100644 index 0000000..8ffa8af --- /dev/null +++ b/src/app/api/user/api-tokens/route.ts @@ -0,0 +1,114 @@ +/** + * Component: User API Token Routes (self-service) + * Documentation: documentation/backend/services/api-tokens.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { z } from 'zod'; + +const logger = RMABLogger.create('API.User.ApiTokens'); + +const API_TOKEN_PREFIX = 'rmab_'; +const TOKEN_RANDOM_BYTES = 32; + +const CreateTokenSchema = z.object({ + name: z.string().min(1).max(100), + expiresAt: z.string().datetime().nullable().optional(), +}); + +/** + * GET /api/user/api-tokens + * List the current user's own API tokens + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const tokens = await prisma.apiToken.findMany({ + where: { userId: req.user!.id }, + orderBy: { createdAt: 'desc' }, + }); + + const sanitized = tokens.map((t) => ({ + id: t.id, + name: t.name, + tokenPrefix: t.tokenPrefix, + role: t.role, + lastUsedAt: t.lastUsedAt, + expiresAt: t.expiresAt, + createdAt: t.createdAt, + })); + + return NextResponse.json({ tokens: sanitized }); + } catch (error) { + logger.error('Failed to list user API tokens', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list API tokens' }, { status: 500 }); + } + }); +} + +/** + * POST /api/user/api-tokens + * Create a token for the current user with their own role. Returns full token ONCE. + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + const body = await req.json(); + const { name, expiresAt } = CreateTokenSchema.parse(body); + + // Look up the user's actual role from the database + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { role: true }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Generate the token + 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, 12); // "rmab_" + 7 chars + + const apiToken = await prisma.apiToken.create({ + data: { + name, + tokenHash, + tokenPrefix, + role: user.role, // Always the user's own role + createdById: req.user!.id, + userId: req.user!.id, // Token acts as the current user + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }); + + logger.info('User API token created', { tokenId: apiToken.id, name, userId: req.user!.id }); + + return NextResponse.json({ + token: { + id: apiToken.id, + name: apiToken.name, + tokenPrefix: apiToken.tokenPrefix, + role: apiToken.role, + expiresAt: apiToken.expiresAt, + createdAt: apiToken.createdAt, + }, + fullToken, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to create user API token', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Validation error', details: error.errors }, { status: 400 }); + } + + return NextResponse.json({ error: 'Failed to create API token' }, { status: 500 }); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 1d88640..e6accff 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +import { ApiTokensSection } from '@/components/profile/ApiTokensSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -233,6 +234,9 @@ export default function ProfilePage() { )} + + {/* API Tokens */} + ); diff --git a/src/components/profile/ApiTokensSection.tsx b/src/components/profile/ApiTokensSection.tsx new file mode 100644 index 0000000..846da4c --- /dev/null +++ b/src/components/profile/ApiTokensSection.tsx @@ -0,0 +1,328 @@ +/** + * Component: API Tokens Section (Profile Page) + * Documentation: documentation/backend/services/api-tokens.md + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; + +interface ApiToken { + id: string; + name: string; + tokenPrefix: string; + role: string; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; +} + +export function ApiTokensSection() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newTokenName, setNewTokenName] = useState(''); + const [newTokenExpiry, setNewTokenExpiry] = useState('never'); + const [showCreateForm, setShowCreateForm] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const fetchTokens = useCallback(async () => { + try { + const response = await fetchWithAuth('/api/user/api-tokens'); + if (response.ok) { + const data = await response.json(); + setTokens(data.tokens); + } + } catch { + setError('Failed to load API tokens'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + const handleCreate = async () => { + if (!newTokenName.trim()) { + setError('Token name is required'); + return; + } + + setCreating(true); + setError(null); + + try { + let expiresAt: string | null = null; + if (newTokenExpiry !== 'never') { + const date = new Date(); + switch (newTokenExpiry) { + case '30d': date.setDate(date.getDate() + 30); break; + case '90d': date.setDate(date.getDate() + 90); break; + case '1y': date.setFullYear(date.getFullYear() + 1); break; + } + expiresAt = date.toISOString(); + } + + const response = await fetchWithAuth('/api/user/api-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTokenName.trim(), expiresAt }), + }); + + if (response.ok) { + const data = await response.json(); + setCreatedToken(data.fullToken); + setNewTokenName(''); + setNewTokenExpiry('never'); + setShowCreateForm(false); + await fetchTokens(); + } else { + const data = await response.json(); + setError(data.error || 'Failed to create token'); + } + } catch { + setError('Failed to create token'); + } finally { + setCreating(false); + } + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + setError(null); + + try { + const response = await fetchWithAuth(`/api/user/api-tokens/${id}`, { + method: 'DELETE', + }); + + if (response.ok) { + setTokens(tokens.filter((t) => t.id !== id)); + } else { + setError('Failed to revoke token'); + } + } catch { + setError('Failed to revoke token'); + } finally { + setDeletingId(null); + } + }; + + const handleCopy = async () => { + if (createdToken) { + await navigator.clipboard.writeText(createdToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+
+
+

+ API Tokens +

+

+ Create personal API tokens for programmatic access to the API. +

+
+
+ +
+
+ {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Newly created token banner */} + {createdToken && ( +
+
+ + + +
+

+ Token created successfully! Copy it now — it won't be shown again. +

+
+ + {createdToken} + + +
+
+ +
+
+ )} + + {/* Create token form */} + {showCreateForm ? ( +
+

Create New Token

+
+
+ + setNewTokenName(e.target.value)} + placeholder="e.g., Home Assistant, Webhook" + className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} + + {/* Token list */} + {loading ? ( +
+
+
+ ) : tokens.length === 0 ? ( +
+ + + +

No API tokens yet

+

Create a token to enable programmatic API access

+
+ ) : ( +
+ + + + + + + + + + + + {tokens.map((token) => ( + + + + + + + + ))} + +
NameTokenLast UsedExpiresActions
{token.name} + + {token.tokenPrefix}... + + {formatDate(token.lastUsedAt)} + {token.expiresAt ? ( + + {formatDate(token.expiresAt)} + {new Date(token.expiresAt) < new Date() && ' (expired)'} + + ) : ( + 'Never' + )} + + +
+
+ )} + + {/* Usage instructions */} +
+

Usage

+

+ Include the token in the Authorization header: +

+
+{`curl -H "Authorization: Bearer rmab_your_token_here" \\
+  ${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
+            
+
+
+
+
+ ); +} diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index ce44d33..b42940d 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -4,12 +4,15 @@ */ import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; import { verifyAccessToken, TokenPayload } from '../utils/jwt'; import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Auth'); +const API_TOKEN_PREFIX = 'rmab_'; + export interface AuthenticatedRequest extends NextRequest { user?: TokenPayload & { id: string }; } @@ -32,9 +35,47 @@ function extractToken(request: NextRequest): string | null { return parts[1]; } +/** + * Authenticate via static API token (rmab_ prefix). + * Returns a synthetic TokenPayload if valid, null otherwise. + * Updates lastUsedAt asynchronously. + */ +async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash }, + include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + }); + + if (!apiToken) return null; + + // Check expiration + if (apiToken.expiresAt && apiToken.expiresAt < new Date()) { + logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix }); + return null; + } + + // Update lastUsedAt (fire-and-forget) + prisma.apiToken.update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }).catch(() => {}); + + // Use the token's target user (userId), not the creator (createdById) + const user = apiToken.tokenUser; + return { + sub: user.id, + id: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: apiToken.role, + }; +} + /** * Middleware: Require authentication - * Verifies JWT token and adds user to request + * Verifies JWT token or static API token and adds user to request */ export async function requireAuth( request: NextRequest, @@ -53,6 +94,26 @@ export async function requireAuth( ); } + // Check if this is a static API token + if (token.startsWith(API_TOKEN_PREFIX)) { + const apiUser = await authenticateApiToken(token); + if (!apiUser) { + logger.error('API token authentication failed'); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Invalid or expired API token', + }, + { status: 401 } + ); + } + + const authenticatedRequest = request as AuthenticatedRequest; + authenticatedRequest.user = apiUser; + return handler(authenticatedRequest); + } + + // Fall back to JWT verification const payload = verifyAccessToken(token); if (!payload) {