mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge pull request #127 from borski/feature/per-user-api-tokens
Add per-user API tokens with security hardening
This commit is contained in:
@@ -68,6 +68,8 @@ model User {
|
|||||||
goodreadsShelves GoodreadsShelf[]
|
goodreadsShelves GoodreadsShelf[]
|
||||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||||
|
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||||
|
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||||
watchedSeries WatchedSeries[]
|
watchedSeries WatchedSeries[]
|
||||||
watchedAuthors WatchedAuthor[]
|
watchedAuthors WatchedAuthor[]
|
||||||
|
|
||||||
@@ -498,6 +500,34 @@ model ReportedIssue {
|
|||||||
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
|
// 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 {
|
model GoodreadsShelf {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const getTabValidation = (
|
|||||||
return validated.paths;
|
return validated.paths;
|
||||||
case 'ebook':
|
case 'ebook':
|
||||||
case 'bookdate':
|
case 'bookdate':
|
||||||
|
case 'api':
|
||||||
return true; // These tabs handle their own saving
|
return true; // These tabs handle their own saving
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@@ -228,4 +229,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
|||||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||||
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
|
||||||
|
{ id: 'api' as const, label: 'API', icon: '🔑' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -243,4 +243,4 @@ export interface BookDateModel {
|
|||||||
/**
|
/**
|
||||||
* Tab identifier type
|
* 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';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { PathsTab } from './tabs/PathsTab/PathsTab';
|
|||||||
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
import { EbookTab } from './tabs/EbookTab/EbookTab';
|
||||||
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab';
|
import { NotificationsTab } from './tabs/NotificationsTab';
|
||||||
|
import { ApiTab } from './tabs/ApiTab/ApiTab';
|
||||||
|
|
||||||
// Types and Helpers
|
// Types and Helpers
|
||||||
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
|
||||||
@@ -346,8 +347,11 @@ export default function AdminSettings() {
|
|||||||
{/* Notifications Tab */}
|
{/* Notifications Tab */}
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
{activeTab === 'notifications' && <NotificationsTab />}
|
||||||
|
|
||||||
|
{/* API Tab */}
|
||||||
|
{activeTab === 'api' && <ApiTab />}
|
||||||
|
|
||||||
{/* Save Button (only for tabs that save through main page) */}
|
{/* Save Button (only for tabs that save through main page) */}
|
||||||
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
|
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && activeTab !== 'api' && (
|
||||||
<div className="mt-8 flex gap-4">
|
<div className="mt-8 flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={saveSettings}
|
onClick={saveSettings}
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Management Tab (Admin)
|
||||||
|
* 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;
|
||||||
|
createdBy: string;
|
||||||
|
createdById: string;
|
||||||
|
tokenUser: string;
|
||||||
|
tokenUserId: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserOption {
|
||||||
|
id: string;
|
||||||
|
plexUsername: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiTab() {
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [users, setUsers] = useState<UserOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
|
||||||
|
const [newTokenUserId, setNewTokenUserId] = useState('');
|
||||||
|
const [newTokenRole, setNewTokenRole] = useState('');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTokens = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/api-tokens');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setTokens(data.tokens);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load API tokens');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/admin/users');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data.users.map((u: any) => ({ id: u.id, plexUsername: u.plexUsername, role: u.role })));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical, user selector just won't populate
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens();
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchTokens, fetchUsers]);
|
||||||
|
|
||||||
|
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 body: Record<string, any> = { name: newTokenName.trim(), expiresAt };
|
||||||
|
if (newTokenUserId) body.userId = newTokenUserId;
|
||||||
|
if (newTokenRole) body.role = newTokenRole;
|
||||||
|
|
||||||
|
const response = await fetchWithAuth('/api/admin/api-tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCreatedToken(data.fullToken);
|
||||||
|
setNewTokenName('');
|
||||||
|
setNewTokenExpiry('never');
|
||||||
|
setNewTokenUserId('');
|
||||||
|
setNewTokenRole('');
|
||||||
|
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/admin/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',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// When a user is selected, default the role to their actual role
|
||||||
|
const handleUserChange = (userId: string) => {
|
||||||
|
setNewTokenUserId(userId);
|
||||||
|
if (userId) {
|
||||||
|
const selectedUser = users.find((u) => u.id === userId);
|
||||||
|
if (selectedUser && !newTokenRole) {
|
||||||
|
setNewTokenRole(selectedUser.role);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNewTokenRole('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">API Tokens</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Manage API tokens for all users. Create tokens for any user with any role for programmatic access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newly created token banner */}
|
||||||
|
{createdToken && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
Token created successfully! Copy it now — it won't be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||||
|
{createdToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreatedToken(null)}
|
||||||
|
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create token form */}
|
||||||
|
{showCreateForm ? (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Expiration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newTokenExpiry}
|
||||||
|
onChange={(e) => setNewTokenExpiry(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
<option value="1y">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
User (acts as)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newTokenUserId}
|
||||||
|
onChange={(e) => handleUserChange(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Current user (default)</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.plexUsername} ({u.role})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Role override
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newTokenRole}
|
||||||
|
onChange={(e) => setNewTokenRole(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">User's default role</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newTokenName.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create Token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); setNewTokenUserId(''); setNewTokenRole(''); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Create New Token
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
{tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||||
|
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Acts As</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Created By</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||||
|
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{token.tokenPrefix}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.tokenUser}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
token.role === 'admin'
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{token.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{formatDate(token.lastUsedAt)}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
{token.expiresAt ? (
|
||||||
|
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||||
|
{formatDate(token.expiresAt)}
|
||||||
|
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(token.id)}
|
||||||
|
disabled={deletingId === token.id}
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage instructions */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||||
|
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||||
|
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
|
||||||
|
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 rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
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 rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
|
||||||
|
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 rateLimit = checkApiTokenRevokeRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token revoke attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
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 rateLimit = checkApiTokenCreateRateLimit(req.user!.id);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many API token create attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Retry-After': String(rateLimit.retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||||
|
import { ApiTokensSection } from '@/components/profile/ApiTokensSection';
|
||||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||||
|
|
||||||
const statConfig = [
|
const statConfig = [
|
||||||
@@ -240,6 +241,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* API Tokens */}
|
||||||
|
<ApiTokensSection />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<ApiToken[]>([]);
|
||||||
|
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<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(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 (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
API Tokens
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Create personal API tokens for programmatic access to the API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Newly created token banner */}
|
||||||
|
{createdToken && (
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
Token created successfully! Copy it now — it won't be shown again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||||
|
{createdToken}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreatedToken(null)}
|
||||||
|
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create token form */}
|
||||||
|
{showCreateForm ? (
|
||||||
|
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Expiration
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newTokenExpiry}
|
||||||
|
onChange={(e) => setNewTokenExpiry(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
<option value="1y">1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newTokenName.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create Token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Create New Token
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm">No API tokens yet</p>
|
||||||
|
<p className="text-xs mt-1">Create a token to enable programmatic API access</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Token</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||||
|
<th className="text-left py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Expires</th>
|
||||||
|
<th className="text-right py-3 px-2 font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
|
||||||
|
<td className="py-3 px-2">
|
||||||
|
<code className="text-xs bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{token.tokenPrefix}...
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{formatDate(token.lastUsedAt)}</td>
|
||||||
|
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
{token.expiresAt ? (
|
||||||
|
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
|
||||||
|
{formatDate(token.expiresAt)}
|
||||||
|
{new Date(token.expiresAt) < new Date() && ' (expired)'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(token.id)}
|
||||||
|
disabled={deletingId === token.id}
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingId === token.id ? 'Revoking...' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage instructions */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Usage</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Include the token in the <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-xs">Authorization</code> header:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
|
||||||
|
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
|
||||||
|
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||||
import { prisma } from '../db';
|
import { prisma } from '../db';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Auth');
|
const logger = RMABLogger.create('Auth');
|
||||||
|
|
||||||
|
const API_TOKEN_PREFIX = 'rmab_';
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends NextRequest {
|
export interface AuthenticatedRequest extends NextRequest {
|
||||||
user?: TokenPayload & { id: string };
|
user?: TokenPayload & { id: string };
|
||||||
}
|
}
|
||||||
@@ -32,9 +35,66 @@ function extractToken(request: NextRequest): string | null {
|
|||||||
return parts[1];
|
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,
|
||||||
|
deletedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiToken) return null;
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||||
|
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject tokens for soft-deleted users
|
||||||
|
const user = apiToken.tokenUser;
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
logger.warn('API token used by deleted or missing user', {
|
||||||
|
tokenPrefix: apiToken.tokenPrefix,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
sub: user.id,
|
||||||
|
id: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: apiToken.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware: Require authentication
|
* 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(
|
export async function requireAuth(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -53,6 +113,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);
|
const payload = verifyAccessToken(token);
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@@ -69,9 +149,13 @@ export async function requireAuth(
|
|||||||
// Verify user still exists in database
|
// Verify user still exists in database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
deletedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
logger.error('User not found in database', { userId: payload.sub });
|
logger.error('User not found in database', { userId: payload.sub });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const current = buckets.get(key);
|
||||||
|
|
||||||
|
if (!current || now >= current.resetAt) {
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= maxRequests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
current.count += 1;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
|
||||||
|
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export const createPrismaMock = () => ({
|
|||||||
bookDateSwipe: createModelMock(),
|
bookDateSwipe: createModelMock(),
|
||||||
goodreadsShelf: createModelMock(),
|
goodreadsShelf: createModelMock(),
|
||||||
goodreadsBookMapping: createModelMock(),
|
goodreadsBookMapping: createModelMock(),
|
||||||
|
apiToken: createModelMock(),
|
||||||
work: createModelMock(),
|
work: createModelMock(),
|
||||||
workAsin: createModelMock(),
|
workAsin: createModelMock(),
|
||||||
watchedSeries: createModelMock(),
|
watchedSeries: createModelMock(),
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
* Documentation: documentation/backend/services/auth.md
|
* Documentation: documentation/backend/services/auth.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const verifyAccessTokenMock = vi.fn();
|
const verifyAccessTokenMock = vi.fn();
|
||||||
@@ -29,6 +31,11 @@ const makeRequest = (authHeader?: string) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to create a valid API token hash for testing
|
||||||
|
const createTestApiToken = (token: string) => {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
describe('auth middleware', () => {
|
describe('auth middleware', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -159,6 +166,98 @@ describe('auth middleware', () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects JWT tokens for soft-deleted users', async () => {
|
||||||
|
verifyAccessTokenMock.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
plexId: 'plex-1',
|
||||||
|
username: 'user',
|
||||||
|
role: 'user',
|
||||||
|
iat: 1,
|
||||||
|
exp: 2,
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/user not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API token authentication', () => {
|
||||||
|
const testToken = 'rmab_test1234567890abcdef';
|
||||||
|
const testTokenHash = createTestApiToken(testToken);
|
||||||
|
|
||||||
|
it('rejects API tokens for soft-deleted users', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'deleteduser',
|
||||||
|
role: 'user',
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects API tokens for missing users', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: null,
|
||||||
|
});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, vi.fn());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(payload.message).toMatch(/invalid.*expired/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid API tokens for active users', async () => {
|
||||||
|
prismaMock.apiToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'token-1',
|
||||||
|
tokenHash: testTokenHash,
|
||||||
|
role: 'user',
|
||||||
|
expiresAt: null,
|
||||||
|
tokenUser: {
|
||||||
|
id: 'user-1',
|
||||||
|
plexUsername: 'activeuser',
|
||||||
|
role: 'user',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismaMock.apiToken.update.mockResolvedValue({});
|
||||||
|
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||||
|
|
||||||
|
const handler = vi.fn(async (req: any) =>
|
||||||
|
NextResponse.json({ ok: true, userId: req.user?.id })
|
||||||
|
);
|
||||||
|
const response = await requireAuth(makeRequest(`Bearer ${testToken}`) as any, handler);
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalled();
|
||||||
|
expect(payload.userId).toBe('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns current user from token', async () => {
|
it('returns current user from token', async () => {
|
||||||
verifyAccessTokenMock.mockReturnValue({
|
verifyAccessTokenMock.mockReturnValue({
|
||||||
sub: 'user-1',
|
sub: 'user-1',
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Component: API Token Rate Limit Tests
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
checkApiTokenCreateRateLimit,
|
||||||
|
checkApiTokenRevokeRateLimit,
|
||||||
|
} from '@/lib/utils/apiTokenRateLimit';
|
||||||
|
|
||||||
|
describe('API Token Rate Limiting', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkApiTokenCreateRateLimit', () => {
|
||||||
|
it('allows requests under the limit', () => {
|
||||||
|
const actorId = 'user-create-1';
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const result = checkApiTokenCreateRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks requests over the limit (10/min)', () => {
|
||||||
|
const actorId = 'user-create-2';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th request should be blocked
|
||||||
|
const result = checkApiTokenCreateRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets after the window expires', () => {
|
||||||
|
const actorId = 'user-create-3';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be blocked
|
||||||
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(false);
|
||||||
|
|
||||||
|
// Advance time past the window (60 seconds)
|
||||||
|
vi.advanceTimersByTime(61 * 1000);
|
||||||
|
|
||||||
|
// Should be allowed again
|
||||||
|
expect(checkApiTokenCreateRateLimit(actorId).allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks different actors separately', () => {
|
||||||
|
const actor1 = 'user-create-4';
|
||||||
|
const actor2 = 'user-create-5';
|
||||||
|
|
||||||
|
// Use up actor1's limit
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
checkApiTokenCreateRateLimit(actor1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// actor1 should be blocked
|
||||||
|
expect(checkApiTokenCreateRateLimit(actor1).allowed).toBe(false);
|
||||||
|
|
||||||
|
// actor2 should still be allowed
|
||||||
|
expect(checkApiTokenCreateRateLimit(actor2).allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkApiTokenRevokeRateLimit', () => {
|
||||||
|
it('allows requests under the limit', () => {
|
||||||
|
const actorId = 'user-revoke-1';
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks requests over the limit (20/min)', () => {
|
||||||
|
const actorId = 'user-revoke-2';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21st request should be blocked
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct retryAfterSeconds', () => {
|
||||||
|
const actorId = 'user-revoke-3';
|
||||||
|
|
||||||
|
// Use up the limit
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance 30 seconds into the window
|
||||||
|
vi.advanceTimersByTime(30 * 1000);
|
||||||
|
|
||||||
|
const result = checkApiTokenRevokeRateLimit(actorId);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
// Should have ~30 seconds left
|
||||||
|
expect(result.retryAfterSeconds).toBeLessThanOrEqual(30);
|
||||||
|
expect(result.retryAfterSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user