From 06447fed713a9782f07c56e83d97fa5306b2fd86 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 10:38:59 +0000 Subject: [PATCH 01/13] chore(db): extend database schema --- .../20260312000000_add_login_token_hash/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 prisma/migrations/20260312000000_add_login_token_hash/migration.sql diff --git a/prisma/migrations/20260312000000_add_login_token_hash/migration.sql b/prisma/migrations/20260312000000_add_login_token_hash/migration.sql new file mode 100644 index 0000000..8934e47 --- /dev/null +++ b/prisma/migrations/20260312000000_add_login_token_hash/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable - Add login_token_hash column for admin-generated login tokens +ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT; From 6a668cc62f240a470a41b626be41a0930c06447c Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 10:40:37 +0000 Subject: [PATCH 02/13] chore(db): extend database schema --- prisma/schema.prisma | 3 +++ 1 file changed, 3 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b841ec6..a613d62 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,6 +57,9 @@ model User { interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny + // Login token (admin-generated, for direct URL login) + loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext) + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user From 274990256498069a862adf4a20aff32a92dcc7cc Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:04:01 +0000 Subject: [PATCH 03/13] feat(auth): add admin login token management --- src/app/admin/users/page.tsx | 29 ++++- .../api/admin/users/[id]/login-token/route.ts | 101 ++++++++++++++++++ src/app/api/admin/users/route.ts | 8 +- .../admin/users/UserPermissionsModal.tsx | 84 +++++++++++++++ 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/app/api/admin/users/[id]/login-token/route.ts diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 1a95f7f..a9f7898 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -29,6 +29,7 @@ interface User { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; _count: { requests: number; }; @@ -220,6 +221,7 @@ function AdminUsersPageContent() { const [globalDownloadAccess, setGlobalDownloadAccess] = useState(true); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [permissionsUserId, setPermissionsUserId] = useState(null); + const [generatedToken, setGeneratedToken] = useState(null); const toast = useToast(); const isLoading = !data && !error; @@ -363,6 +365,24 @@ function AdminUsersPageContent() { } }; + const handleToggleToken = async (user: User, newValue: boolean) => { + try { + if (newValue) { + const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' }); + setGeneratedToken(result.fullToken); + toast.success(`Login token generated for ${user.plexUsername}`); + } else { + await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' }); + setGeneratedToken(null); + toast.success(`Login token revoked for ${user.plexUsername}`); + } + mutate(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to update login token'; + toast.error(errorMsg); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -968,11 +988,15 @@ function AdminUsersPageContent() { {/* User Permissions Modal */} setPermissionsUserId(null)} + onClose={() => { + setPermissionsUserId(null); + setGeneratedToken(null); + }} user={permissionsUser} globalAutoApprove={globalAutoApprove} globalInteractiveSearch={globalInteractiveSearch} globalDownloadAccess={globalDownloadAccess} + generatedToken={generatedToken} onToggleAutoApprove={(user, newValue) => { handleUserAutoApproveToggle(user as User, newValue); }} @@ -982,6 +1006,9 @@ function AdminUsersPageContent() { onToggleDownloadAccess={(user, newValue) => { handleUserDownloadAccessToggle(user as User, newValue); }} + onToggleToken={(user, newValue) => { + handleToggleToken(user as unknown as User, newValue); + }} /> diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts new file mode 100644 index 0000000..026aca0 --- /dev/null +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -0,0 +1,101 @@ +/** + * Component: Admin User Login Token + * Documentation: documentation/backend/services/auth.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 { generateApiToken } from '@/lib/utils/api-token'; +import crypto from 'crypto'; + +const logger = RMABLogger.create('API.Admin.Users.LoginToken'); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true, deletedAt: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (targetUser.deletedAt) { + return NextResponse.json( + { error: 'Cannot generate token for deleted user' }, + { status: 403 } + ); + } + + const { fullToken } = generateApiToken(); + const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex'); + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: tokenHash }, + }); + + logger.info('Admin generated login token for user', { + targetUser: targetUser.plexUsername, + createdBy: req.user!.username, + }); + + return NextResponse.json({ fullToken }, { status: 201 }); + } catch (error) { + logger.error('Failed to generate login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 }); + } + }); + }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: null }, + }); + + logger.info('Admin revoked login token for user', { + targetUser: targetUser.plexUsername, + revokedBy: req.user!.username, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 }); + } + }); + }); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d97373b..dd88ad0 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -33,6 +33,7 @@ export async function GET(request: NextRequest) { autoApproveRequests: true, interactiveSearchAccess: true, downloadAccess: true, + loginTokenHash: true, _count: { select: { requests: true, @@ -44,7 +45,12 @@ export async function GET(request: NextRequest) { }, }); - return NextResponse.json({ users }); + return NextResponse.json({ + users: users.map(({ loginTokenHash, ...u }) => ({ + ...u, + hasLoginToken: loginTokenHash !== null, + })), + }); } catch (error) { logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx index 060c483..843a3cd 100644 --- a/src/components/admin/users/UserPermissionsModal.tsx +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -16,6 +16,7 @@ interface UserPermissionsUser { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; } interface UserPermissionsModalProps { @@ -25,9 +26,11 @@ interface UserPermissionsModalProps { globalAutoApprove: boolean; globalInteractiveSearch: boolean; globalDownloadAccess: boolean; + generatedToken: string | null; onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void; + onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void; } interface PermissionToggleProps { @@ -83,6 +86,78 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, ); } +interface LoginTokenRowProps { + value: boolean; + generatedToken: string | null; + onToggle: () => void; +} + +function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) { + const loginUrl = generatedToken + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}` + : null; + + const copyUrl = async () => { + if (!loginUrl) return; + try { + await navigator.clipboard.writeText(loginUrl); + } catch { + // ignore + } + }; + + return ( +
+
+ +
+
+ Login Token +
+

+ When enabled, this user can log in via a direct URL without credentials +

+
+
+ + {loginUrl && ( +
+

+ Copy the login URL - it won't be shown again +

+
+ + {loginUrl} + + +
+
+ )} +
+ ); +} + export function UserPermissionsModal({ isOpen, onClose, @@ -90,9 +165,11 @@ export function UserPermissionsModal({ globalAutoApprove, globalInteractiveSearch, globalDownloadAccess, + generatedToken, onToggleAutoApprove, onToggleInteractiveSearch, onToggleDownloadAccess, + onToggleToken, }: UserPermissionsModalProps) { if (!user) return null; @@ -201,6 +278,13 @@ export function UserPermissionsModal({ description="When enabled, this user can download audiobook files directly" onToggle={() => onToggleDownloadAccess(user, !downloadValue)} /> + + {/* Login Token */} + onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))} + /> From c373ffffbcf9022f63d73f8c973c544072a1d221 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:07:18 +0000 Subject: [PATCH 04/13] feat(auth):add login via token in frontend --- src/app/api/auth/token/login/route.ts | 77 +++++++++++++++++++++++++++ src/app/auth/token/login/page.tsx | 54 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/app/api/auth/token/login/route.ts create mode 100644 src/app/auth/token/login/page.tsx diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts new file mode 100644 index 0000000..ac952a3 --- /dev/null +++ b/src/app/api/auth/token/login/route.ts @@ -0,0 +1,77 @@ +/** + * Component: Token Login Route + * Documentation: documentation/backend/services/auth.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; +import { RMABLogger } from '@/lib/utils/logger'; +import crypto from 'crypto'; + +const logger = RMABLogger.create('API.Auth.TokenLogin'); + +export async function GET(request: NextRequest) { + try { + const token = request.nextUrl.searchParams.get('token'); + + if (!token) { + return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 }); + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const user = await prisma.user.findFirst({ + where: { + loginTokenHash: tokenHash, + deletedAt: null, + }, + select: { + id: true, + plexId: true, + plexUsername: true, + plexEmail: true, + avatarUrl: true, + role: true, + }, + }); + + if (!user) { + logger.warn('Token login failed - not found or user deleted'); + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + const accessToken = generateAccessToken({ + sub: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: user.role, + }); + + const refreshToken = generateRefreshToken(user.id); + + logger.info('Token login successful', { username: user.plexUsername }); + + return NextResponse.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.plexUsername, + email: user.plexEmail, + avatarUrl: user.avatarUrl, + role: user.role, + }, + }); + } catch (error) { + logger.error('Token login error', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Authentication failed' }, { status: 500 }); + } +} diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx new file mode 100644 index 0000000..a8b7cce --- /dev/null +++ b/src/app/auth/token/login/page.tsx @@ -0,0 +1,54 @@ +/** + * Component: Token Login Page + * Documentation: documentation/backend/services/auth.md + */ + +'use client'; + +import { Suspense, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; + +function TokenLoginContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setAuthData } = useAuth(); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + router.replace('/login'); + return; + } + + fetch(`/api/auth/token/login?token=${encodeURIComponent(token)}`) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + router.replace('/login'); + return; + } + + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + localStorage.setItem('user', JSON.stringify(data.user)); + + setAuthData(data.user, data.accessToken); + router.push('/'); + }) + .catch(() => { + router.replace('/login'); + }); + }, [searchParams, router, setAuthData]); + + return null; +} + +export default function TokenLoginPage() { + return ( + + + + ); +} From e98ac8a4e55602cbc49e59109b6bae700adc507b Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:57:44 +0000 Subject: [PATCH 05/13] fix(auth): redirect after login with token --- src/app/auth/token/login/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx index a8b7cce..2aff00a 100644 --- a/src/app/auth/token/login/page.tsx +++ b/src/app/auth/token/login/page.tsx @@ -35,7 +35,7 @@ function TokenLoginContent() { localStorage.setItem('user', JSON.stringify(data.user)); setAuthData(data.user, data.accessToken); - router.push('/'); + window.location.href = '/'; }) .catch(() => { router.replace('/login'); From 6af15b9622ba84ba45796f25797f270e2e39723e Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:59:49 +0000 Subject: [PATCH 06/13] docs(auth): document token authentication flow --- documentation/TABLEOFCONTENTS.md | 1 + documentation/backend/services/auth.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 9e6769e..2375f31 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -5,6 +5,7 @@ ## Authentication & Users - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) +- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) diff --git a/documentation/backend/services/auth.md b/documentation/backend/services/auth.md index 62d8878..5011789 100644 --- a/documentation/backend/services/auth.md +++ b/documentation/backend/services/auth.md @@ -249,6 +249,13 @@ oidc.admin_claim_value = 'readmeabook-admin' - **Admin Settings:** OIDC section in `/admin/settings` (auth tab) - **Library:** `openid-client` (OIDC discovery, token exchange, PKCE) +## Admin-Generated Login Token + +- Login token stored as SHA-256 hash in `User.loginTokenHash` +- Admin generates/revokes via user permissions modal +- User login with token `/auth/token/login?token=rmab_...` +- Invalid token redirects to `/login` + ## Security - Never log tokens From b20673e7eac3e97a17622ff1db6a246be28993fe Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 12:20:41 +0000 Subject: [PATCH 07/13] test(auth): add tests for token authentication --- tests/api/admin-login-token.routes.test.ts | 106 +++++++++++++++++++++ tests/api/auth-login-token.routes.test.ts | 73 ++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/api/admin-login-token.routes.test.ts create mode 100644 tests/api/auth-login-token.routes.test.ts diff --git a/tests/api/admin-login-token.routes.test.ts b/tests/api/admin-login-token.routes.test.ts new file mode 100644 index 0000000..bd5af3c --- /dev/null +++ b/tests/api/admin-login-token.routes.test.ts @@ -0,0 +1,106 @@ +/** + * Component: Admin User Login Token Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const generateApiTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/utils/api-token', () => ({ + generateApiToken: generateApiTokenMock, +})); + +describe('Admin login token routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token' }); + }); + + describe('POST /api/admin/users/[id]/login-token', () => { + it('generates a login token for an active user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'testuser', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'u1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.fullToken).toBe('rmab_test_token'); + }); + + it('returns 404 when user does not exist', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + + it('returns 403 when user is deleted', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'deleteduser', + deletedAt: new Date(), + }); + + const { POST } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'u2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/deleted user/); + }); + }); + + describe('DELETE /api/admin/users/[id]/login-token', () => { + it('revokes the login token for a user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + plexUsername: 'testuser', + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); + + it('returns 404 when user does not exist', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/admin/users/[id]/login-token/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + }); +}); diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts new file mode 100644 index 0000000..861aca2 --- /dev/null +++ b/tests/api/auth-login-token.routes.test.ts @@ -0,0 +1,73 @@ +/** + * Component: Token Login Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const generateAccessTokenMock = vi.hoisted(() => vi.fn()); +const generateRefreshTokenMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: generateAccessTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +describe('GET /api/auth/token/login', () => { + beforeEach(() => { + vi.clearAllMocks(); + generateAccessTokenMock.mockReturnValue('access-token'); + generateRefreshTokenMock.mockReturnValue('refresh-token'); + }); + + it('authenticates user with a valid token', async () => { + prismaMock.user.findFirst.mockResolvedValueOnce({ + id: 'u1', + plexId: 'plex-1', + plexUsername: 'testuser', + plexEmail: 'test@example.com', + avatarUrl: null, + role: 'user', + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { GET } = await import('@/app/api/auth/token/login/route'); + const request = { nextUrl: { searchParams: new URLSearchParams('token=rmab_valid_token') } }; + const response = await GET(request as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.accessToken).toBe('access-token'); + expect(payload.refreshToken).toBe('refresh-token'); + expect(payload.user.username).toBe('testuser'); + expect(payload.user.email).toBe('test@example.com'); + }); + + it('returns 400 when token parameter is missing', async () => { + const { GET } = await import('@/app/api/auth/token/login/route'); + const request = { nextUrl: { searchParams: new URLSearchParams() } }; + const response = await GET(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Missing token/); + }); + + it('returns 401 when token is invalid or user not found', async () => { + prismaMock.user.findFirst.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/auth/token/login/route'); + const request = { nextUrl: { searchParams: new URLSearchParams('token=rmab_invalid') } }; + const response = await GET(request as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toMatch(/Invalid token/); + }); +}); From 81712ad3ce7517c8985f18efad1b8ecd26324158 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 17:15:07 +0000 Subject: [PATCH 08/13] fix(auth): send login token in POST body --- documentation/backend/services/auth.md | 3 ++- src/app/api/auth/token/login/route.ts | 4 ++-- src/app/auth/token/login/page.tsx | 8 ++++++-- tests/api/auth-login-token.routes.test.ts | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/documentation/backend/services/auth.md b/documentation/backend/services/auth.md index 5011789..93c0aeb 100644 --- a/documentation/backend/services/auth.md +++ b/documentation/backend/services/auth.md @@ -253,7 +253,8 @@ oidc.admin_claim_value = 'readmeabook-admin' - Login token stored as SHA-256 hash in `User.loginTokenHash` - Admin generates/revokes via user permissions modal -- User login with token `/auth/token/login?token=rmab_...` +- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body +- API: `POST /api/auth/token/login` with `{ token }` in JSON body - Invalid token redirects to `/login` ## Security diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts index ac952a3..f95d343 100644 --- a/src/app/api/auth/token/login/route.ts +++ b/src/app/api/auth/token/login/route.ts @@ -11,9 +11,9 @@ import crypto from 'crypto'; const logger = RMABLogger.create('API.Auth.TokenLogin'); -export async function GET(request: NextRequest) { +export async function POST(request: NextRequest) { try { - const token = request.nextUrl.searchParams.get('token'); + const { token } = await request.json(); if (!token) { return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 }); diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx index 2aff00a..84b3ad9 100644 --- a/src/app/auth/token/login/page.tsx +++ b/src/app/auth/token/login/page.tsx @@ -22,7 +22,11 @@ function TokenLoginContent() { return; } - fetch(`/api/auth/token/login?token=${encodeURIComponent(token)}`) + fetch('/api/auth/token/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) .then((res) => res.json()) .then((data) => { if (data.error) { @@ -35,7 +39,7 @@ function TokenLoginContent() { localStorage.setItem('user', JSON.stringify(data.user)); setAuthData(data.user, data.accessToken); - window.location.href = '/'; + window.location.href = '/'; }) .catch(() => { router.replace('/login'); diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts index 861aca2..c72a2eb 100644 --- a/tests/api/auth-login-token.routes.test.ts +++ b/tests/api/auth-login-token.routes.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/utils/jwt', () => ({ generateRefreshToken: generateRefreshTokenMock, })); -describe('GET /api/auth/token/login', () => { +describe('POST /api/auth/token/login', () => { beforeEach(() => { vi.clearAllMocks(); generateAccessTokenMock.mockReturnValue('access-token'); @@ -37,9 +37,9 @@ describe('GET /api/auth/token/login', () => { }); prismaMock.user.update.mockResolvedValueOnce({}); - const { GET } = await import('@/app/api/auth/token/login/route'); - const request = { nextUrl: { searchParams: new URLSearchParams('token=rmab_valid_token') } }; - const response = await GET(request as any); + const { POST } = await import('@/app/api/auth/token/login/route'); + const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_valid_token' }) }; + const response = await POST(request as any); const payload = await response.json(); expect(response.status).toBe(200); @@ -50,9 +50,9 @@ describe('GET /api/auth/token/login', () => { }); it('returns 400 when token parameter is missing', async () => { - const { GET } = await import('@/app/api/auth/token/login/route'); - const request = { nextUrl: { searchParams: new URLSearchParams() } }; - const response = await GET(request as any); + const { POST } = await import('@/app/api/auth/token/login/route'); + const request = { json: vi.fn().mockResolvedValue({}) }; + const response = await POST(request as any); const payload = await response.json(); expect(response.status).toBe(400); @@ -62,9 +62,9 @@ describe('GET /api/auth/token/login', () => { it('returns 401 when token is invalid or user not found', async () => { prismaMock.user.findFirst.mockResolvedValueOnce(null); - const { GET } = await import('@/app/api/auth/token/login/route'); - const request = { nextUrl: { searchParams: new URLSearchParams('token=rmab_invalid') } }; - const response = await GET(request as any); + const { POST } = await import('@/app/api/auth/token/login/route'); + const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_invalid' }) }; + const response = await POST(request as any); const payload = await response.json(); expect(response.status).toBe(401); From d73d13aa2621e0375576bb07f1d138c1c987d37f Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 17:45:25 +0000 Subject: [PATCH 09/13] security(auth): add rate limiting to token login endpoint --- src/app/api/auth/token/login/route.ts | 13 ++++ src/lib/utils/authRateLimit.ts | 89 +++++++++++++++++++++++ tests/api/auth-login-token.routes.test.ts | 34 +++++++-- 3 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/lib/utils/authRateLimit.ts diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts index f95d343..f5f7c42 100644 --- a/src/app/api/auth/token/login/route.ts +++ b/src/app/api/auth/token/login/route.ts @@ -7,12 +7,25 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { RMABLogger } from '@/lib/utils/logger'; +import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; import crypto from 'crypto'; const logger = RMABLogger.create('API.Auth.TokenLogin'); export async function POST(request: NextRequest) { try { + const ip = request.headers.get('x-forwarded-for') ?? 'unknown'; + const rateLimit = checkTokenLoginRateLimit(ip); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many login attempts. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) }, + } + ); + } + const { token } = await request.json(); if (!token) { diff --git a/src/lib/utils/authRateLimit.ts b/src/lib/utils/authRateLimit.ts new file mode 100644 index 0000000..0d0a56c --- /dev/null +++ b/src/lib/utils/authRateLimit.ts @@ -0,0 +1,89 @@ +/** + * Component: Auth Rate Limiting + * Documentation: documentation/backend/services/auth.md + * + * In-memory fixed-window rate limiter with lazy eviction and periodic sweep + * to prevent unbounded memory growth. + */ + +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +/** Number of checkRateLimit calls since the last full sweep */ +let checkCount = 0; + +/** How often (in calls) to perform a full sweep of expired buckets */ +const SWEEP_INTERVAL = 100; + +/** + * Sweep the entire bucket map and delete all expired entries. + * Called automatically every SWEEP_INTERVAL checks. + */ +function sweepExpiredBuckets(): void { + const now = Date.now(); + for (const [key, bucket] of buckets) { + if (now >= bucket.resetAt) { + buckets.delete(key); + } + } +} + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + + // Periodic full sweep every SWEEP_INTERVAL calls + checkCount += 1; + if (checkCount >= SWEEP_INTERVAL) { + checkCount = 0; + sweepExpiredBuckets(); + } + + const current = buckets.get(key); + + // Lazy eviction: if the bucket is expired, delete it and start fresh + if (!current || now >= current.resetAt) { + if (current) { + buckets.delete(key); + } + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; + } + + 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)), + }; +} + +/** 10 attempts per 15 minutes per IP */ +export function checkTokenLoginRateLimit(ip: string): RateLimitResult { + return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000); +} + +/** Reset all buckets and the sweep counter. For testing only. */ +export function _resetBuckets(): void { + buckets.clear(); + checkCount = 0; +} + +/** Get the current number of tracked buckets. For testing only. */ +export function _getBucketCount(): number { + return buckets.size; +} diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts index c72a2eb..12015a7 100644 --- a/tests/api/auth-login-token.routes.test.ts +++ b/tests/api/auth-login-token.routes.test.ts @@ -9,6 +9,7 @@ import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); const generateAccessTokenMock = vi.hoisted(() => vi.fn()); const generateRefreshTokenMock = vi.hoisted(() => vi.fn()); +const checkTokenLoginRateLimitMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -19,11 +20,23 @@ vi.mock('@/lib/utils/jwt', () => ({ generateRefreshToken: generateRefreshTokenMock, })); +vi.mock('@/lib/utils/authRateLimit', () => ({ + checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, +})); + +function makeRequest(body: Record, ip = '127.0.0.1') { + return { + headers: { get: vi.fn().mockReturnValue(ip) }, + json: vi.fn().mockResolvedValue(body), + }; +} + describe('POST /api/auth/token/login', () => { beforeEach(() => { vi.clearAllMocks(); generateAccessTokenMock.mockReturnValue('access-token'); generateRefreshTokenMock.mockReturnValue('refresh-token'); + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: true, retryAfterSeconds: 900 }); }); it('authenticates user with a valid token', async () => { @@ -38,8 +51,7 @@ describe('POST /api/auth/token/login', () => { prismaMock.user.update.mockResolvedValueOnce({}); const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_valid_token' }) }; - const response = await POST(request as any); + const response = await POST(makeRequest({ token: 'rmab_valid_token' }) as any); const payload = await response.json(); expect(response.status).toBe(200); @@ -51,8 +63,7 @@ describe('POST /api/auth/token/login', () => { it('returns 400 when token parameter is missing', async () => { const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({}) }; - const response = await POST(request as any); + const response = await POST(makeRequest({}) as any); const payload = await response.json(); expect(response.status).toBe(400); @@ -63,11 +74,22 @@ describe('POST /api/auth/token/login', () => { prismaMock.user.findFirst.mockResolvedValueOnce(null); const { POST } = await import('@/app/api/auth/token/login/route'); - const request = { json: vi.fn().mockResolvedValue({ token: 'rmab_invalid' }) }; - const response = await POST(request as any); + const response = await POST(makeRequest({ token: 'rmab_invalid' }) as any); const payload = await response.json(); expect(response.status).toBe(401); expect(payload.error).toMatch(/Invalid token/); }); + + it('returns 429 when rate limit is exceeded', async () => { + checkTokenLoginRateLimitMock.mockReturnValue({ allowed: false, retryAfterSeconds: 600 }); + + const { POST } = await import('@/app/api/auth/token/login/route'); + const response = await POST(makeRequest({ token: 'rmab_any' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(429); + expect(payload.error).toMatch(/Too many login attempts/); + expect(response.headers.get('Retry-After')).toBe('600'); + }); }); From 5ae58a36b4c245e39bd181ba47133b90cefc4945 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 18:02:03 +0000 Subject: [PATCH 10/13] refactor(auth): reuse tokenHash from generateApiToken --- src/app/api/admin/users/[id]/login-token/route.ts | 4 +--- tests/api/admin-login-token.routes.test.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts index 026aca0..05fb69d 100644 --- a/src/app/api/admin/users/[id]/login-token/route.ts +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -8,7 +8,6 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; import { generateApiToken } from '@/lib/utils/api-token'; -import crypto from 'crypto'; const logger = RMABLogger.create('API.Admin.Users.LoginToken'); @@ -37,8 +36,7 @@ export async function POST( ); } - const { fullToken } = generateApiToken(); - const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex'); + const { fullToken, tokenHash } = generateApiToken(); await prisma.user.update({ where: { id }, diff --git a/tests/api/admin-login-token.routes.test.ts b/tests/api/admin-login-token.routes.test.ts index bd5af3c..e6b2eb4 100644 --- a/tests/api/admin-login-token.routes.test.ts +++ b/tests/api/admin-login-token.routes.test.ts @@ -32,7 +32,7 @@ describe('Admin login token routes', () => { authRequest = { user: { id: 'admin-1', username: 'admin', role: 'admin' }, json: vi.fn() }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); - generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token' }); + generateApiTokenMock.mockReturnValue({ fullToken: 'rmab_test_token', tokenHash: 'hash_abc123' }); }); describe('POST /api/admin/users/[id]/login-token', () => { From 4d3af02dc83b5b9b5b0c4d796d06ab642e9d1280 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 18:09:37 +0000 Subject: [PATCH 11/13] refactor(types): remove unsafe User double-cast --- src/app/admin/users/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index a9f7898..48666a5 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -365,7 +365,7 @@ function AdminUsersPageContent() { } }; - const handleToggleToken = async (user: User, newValue: boolean) => { + const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => { try { if (newValue) { const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' }); @@ -1007,7 +1007,7 @@ function AdminUsersPageContent() { handleUserDownloadAccessToggle(user as User, newValue); }} onToggleToken={(user, newValue) => { - handleToggleToken(user as unknown as User, newValue); + handleToggleToken(user, newValue); }} /> From f8c6ff38824e2430066207a5b36191501d79be8e Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 18:25:20 +0000 Subject: [PATCH 12/13] fix(ui): show toast when clipboard copy fails --- src/components/admin/users/UserPermissionsModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx index 843a3cd..a8a8e8b 100644 --- a/src/components/admin/users/UserPermissionsModal.tsx +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -6,6 +6,7 @@ 'use client'; import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; interface UserPermissionsUser { id: string; @@ -93,6 +94,7 @@ interface LoginTokenRowProps { } function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) { + const toast = useToast(); const loginUrl = generatedToken ? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}` : null; @@ -102,7 +104,7 @@ function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) try { await navigator.clipboard.writeText(loginUrl); } catch { - // ignore + toast.error('Failed to copy to clipboard'); } }; From dbf13c39d50217d4a3d5014f8a47916022e042c3 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 18:34:31 +0000 Subject: [PATCH 13/13] fix(ui): show loading state during token authentication --- src/app/auth/token/login/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx index 84b3ad9..9b57aad 100644 --- a/src/app/auth/token/login/page.tsx +++ b/src/app/auth/token/login/page.tsx @@ -46,7 +46,14 @@ function TokenLoginContent() { }); }, [searchParams, router, setAuthData]); - return null; + return ( +
+
+
+

Authenticating...

+
+
+ ); } export default function TokenLoginPage() {