'use strict'; import crypto from 'crypto'; import type { Request } from 'express'; import { EMBY_SERVER_URL } from '../config'; import type { EmbySession } from '../types'; export const SESSION_COOKIE = 'wp_session'; export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days function authHeaderValue(): string { return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"'; } interface EmbyAuthResponse { AccessToken: string; ServerId: string; User: { Id: string; Name: string }; } /** * Server-side Emby login + in-memory session store. The browser only ever * sends its username/password once, to us; we forward that to Emby, and * from then on the browser only holds an opaque session id (cookie) - * never Emby's own access token. This is what makes "the server proxies * everything" possible: the browser has nothing it could use to talk to * Emby even if it tried. See docs/architecture.md. * * Singleton, same shape as PartyService/MusicQueueService - sessions are * in-memory, like party state, so a restart signs everyone out, which is * fine (an Emby login is one click away). */ class AuthServiceClass { private sessions = new Map(); /** Returns a new session id, or null on bad credentials / unreachable Emby. */ async login(username: string, password: string): Promise { if (!EMBY_SERVER_URL) return null; let res: globalThis.Response; try { res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': authHeaderValue() }, body: JSON.stringify({ Username: username, Pw: password }) }); } catch (err) { console.error('[watchparty] Emby login request failed:', (err as Error).message); return null; } if (!res.ok) return null; const data = (await res.json()) as EmbyAuthResponse; const sessionId = crypto.randomUUID(); this.sessions.set(sessionId, { embyToken: data.AccessToken, userId: data.User.Id, userName: data.User.Name, serverId: data.ServerId, // needed to build "open in Emby" deep links createdUtc: Date.now() }); return sessionId; } getSession(sessionId: string): EmbySession | null { const session = this.sessions.get(sessionId); if (!session) return null; if (Date.now() - session.createdUtc > SESSION_TTL_MS) { this.sessions.delete(sessionId); return null; } return session; } endSession(sessionId: string): void { this.sessions.delete(sessionId); } parseCookies(cookieHeader: string | undefined): Record { const out: Record = {}; (cookieHeader || '').split(';').forEach((part) => { const idx = part.indexOf('='); if (idx === -1) return; out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()); }); return out; } /** Reads the caller's session from an Express request's cookie, or null. */ sessionFromRequest(req: Request): EmbySession | null { const cookies = this.parseCookies(req.headers.cookie); const id = cookies[SESSION_COOKIE]; return id ? this.getSession(id) : null; } } export const AuthService = new AuthServiceClass();