// Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const axios = require('axios'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const router = express.Router(); // Persistent JSON file-backed token store — survives restarts const { storeToken, getToken, clearToken } = require('../utils/tokenStore'); // Read EMBY_URL at request time (not module load time) so the value // can be overridden by environment variables set after the module loads. const getEmbyUrl = () => process.env.EMBY_URL; // Strict login limiter: 10 attempts per 15 min, then locked for the window. // Set SKIP_RATE_LIMIT=1 in the test environment to prevent the limiter from // interfering with integration tests (all requests come from 127.0.0.1). const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 10, standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, // only count failures toward the limit message: { success: false, error: 'Too many login attempts, please try again later' } }); /** * @openapi * /api/auth/login: * post: * tags: [Auth] * summary: Authenticate with Emby/Jellyfin * description: | * Authenticates a user against Emby/Jellyfin and sets session cookies. * * **Rate Limiting:** 10 failed attempts per 15 minutes per IP (successful attempts don't count). * * **Authentication Flow:** * 1. Send username and password in request body * 2. Server validates credentials with Emby/Jellyfin * 3. Server sets httpOnly signed cookie `emby_user` containing {id, name, isAdmin} * 4. Server sets `csrf_token` cookie (readable by JS for double-submit pattern) * 5. Response includes user data and CSRF token * * **Cookie Details:** * - `emby_user`: httpOnly, signed, sameSite=strict. Persistent if rememberMe=true (30 days), otherwise session cookie. * - `csrf_token`: httpOnly=false (JS-readable), sameSite=strict. Used for state-changing requests. * * **Security Notes:** * - Password must be 1-256 characters * - Username must be 1-128 characters * - Server rejects with 400 if input validation fails * - Server rejects with 401 if Emby authentication fails * * **x-integration-notes:** After successful login, subsequent requests must: * - Send the emby_user cookie (automatically by browser) * - Send the X-CSRF-Token header (from csrf_token cookie) for POST/PUT/PATCH/DELETE requests * - Use credentials: 'include' in fetch/axios to send cookies * security: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - username * - password * properties: * username: * type: string * minLength: 1 * maxLength: 128 * description: Emby/Jellyfin username * example: "john" * password: * type: string * minLength: 1 * maxLength: 256 * description: Emby/Jellyfin password * example: "password123" * rememberMe: * type: boolean * description: If true, cookie persists for 30 days; otherwise session cookie * example: false * example: * username: "john" * password: "password123" * rememberMe: false * responses: * '200': * description: Login successful * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * user: * type: object * properties: * id: * type: string * description: Emby user ID * example: "abc123def456" * name: * type: string * description: Display name * example: "John Doe" * isAdmin: * type: boolean * description: Admin flag * example: false * csrfToken: * type: string * description: CSRF token for state-changing requests * example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" * example: * success: true * user: * id: "abc123def456" * name: "John Doe" * isAdmin: false * csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" * '400': * description: Invalid input (username or password fails validation) * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * success: false * error: "Invalid username" * '401': * description: Invalid credentials (Emby authentication failed) * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * success: false * error: "Invalid username or password" * x-code-samples: * - lang: curl * label: cURL * source: | * curl -X POST http://localhost:3001/api/auth/login \ * -H "Content-Type: application/json" \ * -c cookies.txt \ * -d '{"username":"john","password":"password123"}' * - lang: JavaScript * label: JavaScript (fetch) * source: | * const response = await fetch('http://localhost:3001/api/auth/login', { * method: 'POST', * headers: { 'Content-Type': 'application/json' }, * credentials: 'include', * body: JSON.stringify({ username: 'john', password: 'password123' }) * }); * const data = await response.json(); * console.log(data.csrfToken); // Save this for subsequent requests * - lang: TypeScript * label: TypeScript * source: | * interface LoginResponse { * success: boolean; * user: { id: string; name: string; isAdmin: boolean }; * csrfToken: string; * } * const response = await fetch('http://localhost:3001/api/auth/login', { * method: 'POST', * headers: { 'Content-Type': 'application/json' }, * credentials: 'include', * body: JSON.stringify({ username: 'john', password: 'password123' }) * }); * const data: LoginResponse = await response.json(); */ router.post('/login', loginLimiter, async (req, res) => { try { const { username, password, rememberMe } = req.body; // Input validation — reject obviously invalid inputs before hitting Emby if (typeof username !== 'string' || username.trim().length === 0 || username.length > 128) { return res.status(400).json({ success: false, error: 'Invalid username' }); } if (typeof password !== 'string' || password.length === 0 || password.length > 256) { return res.status(400).json({ success: false, error: 'Invalid password' }); } console.log(`[Auth] Attempting login for user: ${username.trim()}`); // Authenticate with Emby using a stable DeviceId derived from the username. // Using a deterministic DeviceId causes Emby to reuse the existing session // for this device rather than creating a new one on each login. const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.trim().toLowerCase()).digest('hex').slice(0, 16); const authResponse = await axios.post(`${getEmbyUrl()}/Users/authenticatebyname`, { Username: username.trim(), Pw: password }, { headers: { 'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"` } }); const authData = authResponse.data; // Get user info using the access token const userResponse = await axios.get(`${getEmbyUrl()}/Users/${authData.User.Id || authData.User.id}`, { headers: { 'X-MediaBrowser-Token': authData.AccessToken } }); const user = userResponse.data; const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`); // Store token server-side; it is never sent to the client. storeToken(user.Id, authData.AccessToken); // Set authentication cookie (signed when COOKIE_SECRET is set). // rememberMe=true → persistent cookie, expires in 30 days // rememberMe=false → session cookie, expires when browser closes // secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse // proxy is in front. Without it the app may be accessed over plain HTTP and // secure cookies would never be sent back by the browser. const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); const signed = !!process.env.COOKIE_SECRET; const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy const cookieOptions = { httpOnly: true, secure: secureCookie, sameSite: 'strict', signed, path: '/' }; if (rememberMe) { cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days } res.cookie('emby_user', cookiePayload, cookieOptions); // Issue a CSRF token tied to this session so state-changing endpoints // can validate the double-submit cookie pattern const csrfToken = crypto.randomBytes(32).toString('hex'); res.cookie('csrf_token', csrfToken, { httpOnly: false, // intentionally readable by JS for the double-submit pattern secure: secureCookie, sameSite: 'strict', path: '/' }); res.json({ success: true, user: { id: user.Id, name: user.Name, isAdmin }, csrfToken }); } catch (error) { console.error(`[Auth] Login failed:`, error.message); res.status(401).json({ success: false, error: 'Invalid username or password' }); } }); function parseSessionCookie(req) { const signed = !!process.env.COOKIE_SECRET; const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user; if (!raw || raw === false) return null; // false = tampered signed cookie try { const u = JSON.parse(raw); // Schema validation: require id (string), name (string), isAdmin (boolean) if (typeof u.id !== 'string' || !u.id) return null; if (typeof u.name !== 'string' || !u.name) return null; if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin; return u; } catch { return null; } } /** * @openapi * /api/auth/me: * get: * tags: [Auth] * summary: Get current authenticated user * description: | * Returns the currently authenticated user from the session cookie. * * **Authentication:** Requires valid `emby_user` cookie. * * **Response:** * - If authenticated: returns user data (id, name, isAdmin) * - If not authenticated: returns authenticated: false * * **Use Case:** Check if user is logged in and get user details without re-authenticating. * security: * - CookieAuth: [] * responses: * '200': * description: User data (authenticated or not) * content: * application/json: * schema: * oneOf: * - type: object * properties: * authenticated: * type: boolean * example: true * user: * type: object * properties: * id: * type: string * example: "abc123def456" * name: * type: string * example: "John Doe" * isAdmin: * type: boolean * example: false * - type: object * properties: * authenticated: * type: boolean * example: false * examples: * authenticated: * authenticated: true * user: * id: "abc123def456" * name: "John Doe" * isAdmin: false * notAuthenticated: * authenticated: false * x-code-samples: * - lang: curl * label: cURL * source: | * curl -X GET http://localhost:3001/api/auth/me \ * -b cookies.txt * - lang: JavaScript * label: JavaScript (fetch) * source: | * const response = await fetch('http://localhost:3001/api/auth/me', { * method: 'GET', * credentials: 'include' * }); * const data = await response.json(); * if (data.authenticated) { * console.log('User:', data.user.name); * } */ router.get('/me', (req, res) => { const user = parseSessionCookie(req); if (!user) return res.json({ authenticated: false }); res.json({ authenticated: true, user: { id: user.id, name: user.name, isAdmin: user.isAdmin } }); }); /** * @openapi * /api/auth/csrf: * get: * tags: [Auth] * summary: Refresh CSRF token * description: | * Returns a fresh CSRF token and sets it as a cookie. * * **Purpose:** Lets the SPA get a new CSRF token without re-authenticating * (e.g., after a page reload where the JS variable containing the token was lost). * * **Authentication:** No authentication required (CSRF tokens are issued to all clients). * * **Cookie Details:** * - Sets `csrf_token` cookie (httpOnly=false, readable by JS) * - sameSite=strict, secure when TRUST_PROXY is set * * **Use Case:** Call this endpoint when your application needs a fresh CSRF token * for state-changing requests (POST/PUT/PATCH/DELETE). * security: [] * responses: * '200': * description: CSRF token * content: * application/json: * schema: * type: object * properties: * csrfToken: * type: string * description: Fresh CSRF token for state-changing requests * example: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" * example: * csrfToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" * x-code-samples: * - lang: curl * label: cURL * source: | * curl -X GET http://localhost:3001/api/auth/csrf \ * -c cookies.txt * - lang: JavaScript * label: JavaScript (fetch) * source: | * const response = await fetch('http://localhost:3001/api/auth/csrf', { * method: 'GET', * credentials: 'include' * }); * const data = await response.json(); * const csrfToken = data.csrfToken; // Use this in X-CSRF-Token header */ router.get('/csrf', (req, res) => { const csrfToken = crypto.randomBytes(32).toString('hex'); res.cookie('csrf_token', csrfToken, { httpOnly: false, secure: !!process.env.TRUST_PROXY, sameSite: 'strict', path: '/' }); res.json({ csrfToken }); }); /** * @openapi * /api/auth/logout: * post: * tags: [Auth] * summary: Logout * description: | * Clears session cookies and revokes the Emby/Jellyfin access token. * * **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header. * * **Actions Performed:** * 1. Revokes the Emby/Jellyfin access token on the Emby server * 2. Clears the server-side token store * 3. Clears the `emby_user` cookie * 4. Clears the `csrf_token` cookie * * **Error Handling:** If Emby token revocation fails, the logout still succeeds * (cookies are cleared) but a warning is logged. * * **x-integration-notes:** After logout, the client must discard the CSRF token * and not attempt further authenticated requests until re-authenticating. * security: * - CookieAuth: [] * - CsrfToken: [] * responses: * '200': * description: Logout successful * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * example: * success: true * '401': * description: Not authenticated (no valid session) * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * '403': * description: CSRF token missing or invalid * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * x-code-samples: * - lang: curl * label: cURL * source: | * # Get CSRF token first * CSRF_TOKEN=$(curl -s -c cookies.txt http://localhost:3001/api/auth/csrf | jq -r .csrfToken) * # Logout * curl -X POST http://localhost:3001/api/auth/logout \ * -H "X-CSRF-Token: $CSRF_TOKEN" \ * -b cookies.txt \ * -c cookies.txt * - lang: JavaScript * label: JavaScript (fetch) * source: | * const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', { * method: 'GET', * credentials: 'include' * }); * const { csrfToken } = await csrfResponse.json(); * * const response = await fetch('http://localhost:3001/api/auth/logout', { * method: 'POST', * headers: { 'X-CSRF-Token': csrfToken }, * credentials: 'include' * }); * const data = await response.json(); * console.log(data.success); // true */ router.post('/logout', async (req, res) => { const user = parseSessionCookie(req); if (user) { const stored = getToken(user.id); if (stored) { try { await axios.post(`${getEmbyUrl()}/Sessions/Logout`, {}, { headers: { 'X-MediaBrowser-Token': stored.accessToken } }); console.log(`[Auth] Revoked Emby token for user: ${user.name}`); } catch (err) { console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message); } clearToken(user.id); } } res.clearCookie('emby_user', { httpOnly: true, secure: !!process.env.TRUST_PROXY, sameSite: 'strict', signed: !!process.env.COOKIE_SECRET, path: '/' }); res.clearCookie('csrf_token', { httpOnly: false, secure: !!process.env.TRUST_PROXY, sameSite: 'strict', path: '/' }); res.json({ success: true }); }); module.exports = router;