@startuml seq-auth !theme plain title sofarr — Authentication Sequence actor User as user participant "Browser\n(app.js)" as browser participant "Express\n/api/auth" as auth participant "TokenStore\n(tokens.json)" as tokens participant "Emby\nServer" as emby == Page Load == user -> browser : Navigate to sofarr activate browser browser -> auth : GET /api/auth/me activate auth auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set) alt Cookie exists and valid auth --> browser : { authenticated: true, user: { name, isAdmin } } browser -> auth : GET /api/auth/csrf activate auth auth -> auth : Generate 32-byte hex csrfToken auth --> browser : { csrfToken } + Set csrf_token cookie deactivate auth browser -> browser : store csrfToken in memory browser -> browser : showDashboard() browser -> browser : startAutoRefresh() browser -> browser : dismissSplash() else No cookie / tampered auth --> browser : { authenticated: false } browser -> browser : dismissSplash() browser -> browser : showLogin() end deactivate auth == Login == user -> browser : Enter username + password\n(+ optional rememberMe checkbox) browser -> auth : POST /api/auth/login\n{ username, password, rememberMe } activate auth note right of auth Rate limiter: max 10 failed attempts per IP / 15 min (successful requests excluded) end note auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16] activate emby alt Valid credentials emby --> auth : { User: { Id }, AccessToken } auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken emby --> auth : { Name, Policy: { IsAdministrator } } deactivate emby auth -> tokens : storeToken(userId, AccessToken) note right of tokens Stored server-side only. Never sent to the client. 31-day TTL, atomic JSON write. end note auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie auth -> auth : Generate csrfToken\n(32-byte random hex) auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod) auth --> browser : { success: true, user, csrfToken } browser -> browser : store csrfToken in memory browser -> browser : fadeOutLogin() browser -> browser : showDashboard() browser -> browser : startAutoRefresh() browser -> browser : dismissSplash() else Invalid credentials emby --> auth : 401 Error deactivate emby auth --> browser : { success: false, error: "Invalid username or password" } browser -> browser : showLoginError() end deactivate auth == CSRF Token Refresh (after page reload) == note over browser : csrfToken lost from memory\non hard page reload browser -> auth : GET /api/auth/csrf activate auth auth -> auth : Generate new csrfToken auth --> browser : { csrfToken } + new csrf_token cookie deactivate auth browser -> browser : store new csrfToken in memory == Logout == user -> browser : Click Logout browser -> browser : stopAutoRefresh() browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects) activate auth auth -> auth : Parse emby_user cookie → user auth -> tokens : getToken(user.id) activate tokens tokens --> auth : { accessToken } deactivate tokens auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken activate emby emby --> auth : 204 / error (ignored) deactivate emby auth -> tokens : clearToken(user.id) auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token) auth --> browser : { success: true } deactivate auth browser -> browser : showLogin() deactivate browser @enduml