docs: audit and update all documentation to reflect current codebase
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Failing after 1m5s
Build and Push Docker Image / build (push) Successful in 35s
CI / Security audit (push) Successful in 58s
CI / Tests & coverage (push) Failing after 1m5s
ARCHITECTURE.md: - Node version: 18+ → 22 (Alpine) - Tech stack: add helmet, express-rate-limit, cookie-parser, testing tools - Directory structure: add server/app.js, verifyCsrf.js, tokenStore.js, sanitizeError.js, tests/, docs/, .gitea/workflows/, vitest.config.js - §4.1: document app.js factory (createApp) vs index.js entry point; CSP nonce, rate limiters, CSRF middleware, trust proxy - §4.2: add CSRF Required column; document verifyCsrf; fix auth note - §4.3: add tokenStore.js and sanitizeError.js descriptions - §6 Auth flow: add rememberMe, rate limiter, stable DeviceId, server-side token store, CSRF token issuance, correct cookie TTL (session/30d not 24h) - §9 API: add csrfToken to login response, rememberMe field, 400/429 codes; add GET /api/auth/csrf endpoint; fix /me response; fix /logout CSRF note - §11 Config: add DATA_DIR, COOKIE_SECRET, TRUST_PROXY, NODE_ENV; split into Core / Emby / Service Instances / Tuning sections - §12 Deployment: update Dockerfile description to multi-stage node:22-alpine; add COOKIE_SECRET, TRUST_PROXY, named volume to compose example; add security hardening checklist; add CI/CD table diagrams/seq-auth.puml: - Add TokenStore participant - Add rememberMe, CSRF token issuance, stable DeviceId note - Add login rate limiter note - Add GET /csrf refresh flow - Add server-side token revocation on logout diagrams/class-server.puml: - Add app.js createApp() factory class - Add verifyCsrf middleware class - Add TokenStore and SanitizeError utility classes - Update auth.js routes (add GET /csrf) - Fix relationships: entry → appfn → routes diagrams/component.puml: - Add app.js factory component - Add helmet, express-rate-limit components - Add verifyCsrf middleware component - Add tokenStore.js and sanitizeError.js utility components - Fix wiring: entry → createApp() → mounts routes Dockerfile: - Fix stale comments referencing better-sqlite3 and SQLite server/routes/auth.js: - Fix stale comment: SQLite-backed → JSON file-backed
This commit is contained in:
+2
-2
@@ -27,7 +27,7 @@ LABEL custom.hardware.requirement="None - runs on any Docker-supported platform
|
|||||||
# The /app directory is owned by root; data directory is owned by node
|
# The /app directory is owned by root; data directory is owned by node
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy production deps from deps stage (includes pre-built better-sqlite3)
|
# Copy production deps from deps stage
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy application source owned by root (read-only at runtime)
|
# Copy application source owned by root (read-only at runtime)
|
||||||
@@ -35,7 +35,7 @@ COPY --chown=root:root server/ ./server/
|
|||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
|
|
||||||
# Persistent data directory owned by node user (SQLite token store, logs)
|
# Persistent data directory owned by node user (token store, logs)
|
||||||
RUN mkdir -p /app/data && chown node:node /app/data
|
RUN mkdir -p /app/data && chown node:node /app/data
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
+212
-72
@@ -80,18 +80,42 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
|||||||
|
|
||||||
## 2. Technology Stack
|
## 2. Technology Stack
|
||||||
|
|
||||||
|
### Runtime & Framework
|
||||||
|
|
||||||
| Layer | Technology | Purpose |
|
| Layer | Technology | Purpose |
|
||||||
|-------|-----------|---------|
|
|-------|-----------|------|
|
||||||
| **Runtime** | Node.js 18+ | Server runtime |
|
| **Runtime** | Node.js 22 (Alpine) | Server runtime |
|
||||||
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
||||||
| **HTTP Client** | axios 1.x | External API communication |
|
| **HTTP Client** | axios 1.x | External API communication |
|
||||||
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
||||||
| **Auth** | Emby API + httpOnly cookies | Session management |
|
| **Containerisation** | Docker multi-stage (Alpine) | Production deployment |
|
||||||
| **Caching** | In-memory Map with TTL | Reduce external API load |
|
|
||||||
| **Scheduling** | `setInterval` | Background polling |
|
|
||||||
| **Containerisation** | Docker (Alpine) | Production deployment |
|
|
||||||
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
||||||
|
|
||||||
|
### Security Middleware
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|--------|
|
||||||
|
| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) |
|
||||||
|
| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter |
|
||||||
|
| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) |
|
||||||
|
|
||||||
|
### Auth & Session
|
||||||
|
|
||||||
|
| Component | Technology | Details |
|
||||||
|
|-----------|-----------|--------|
|
||||||
|
| **Identity** | Emby API | `POST /Users/authenticatebyname` |
|
||||||
|
| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set |
|
||||||
|
| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header |
|
||||||
|
| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `vitest` 4.x | Test runner (V8 coverage built-in) |
|
||||||
|
| `supertest` 7.x | HTTP integration testing |
|
||||||
|
| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Directory Structure
|
## 3. Directory Structure
|
||||||
@@ -99,22 +123,26 @@ Admin users can view all users' downloads, see server status, cache statistics,
|
|||||||
```
|
```
|
||||||
sofarr/
|
sofarr/
|
||||||
├── server/ # Backend application
|
├── server/ # Backend application
|
||||||
│ ├── index.js # Entry point: Express setup, middleware, startup
|
│ ├── index.js # Entry point: logging setup, server listen, poller start
|
||||||
|
│ ├── app.js # Express app factory (imported by index.js and tests)
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.js # POST /login, GET /me, POST /logout
|
│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout
|
||||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
|
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art
|
||||||
│ │ ├── emby.js # Proxy routes to Emby API
|
│ │ ├── emby.js # Proxy routes to Emby API
|
||||||
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
||||||
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
||||||
│ │ └── radarr.js # Proxy routes to Radarr API
|
│ │ └── radarr.js # Proxy routes to Radarr API
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ └── requireAuth.js # httpOnly cookie auth middleware
|
│ │ ├── requireAuth.js # httpOnly cookie auth enforcement
|
||||||
|
│ │ └── verifyCsrf.js # CSRF double-submit cookie validation
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
||||||
│ ├── config.js # Multi-instance service configuration parser
|
│ ├── config.js # Multi-instance service configuration parser
|
||||||
│ ├── logger.js # File logger (server.log)
|
│ ├── logger.js # File logger (DATA_DIR/server.log)
|
||||||
│ ├── poller.js # Background polling engine + timing
|
│ ├── poller.js # Background polling engine + timing
|
||||||
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
│ ├── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||||
|
│ ├── sanitizeError.js # Redacts secrets from error messages before logging
|
||||||
|
│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL)
|
||||||
├── public/ # Static frontend (served by Express)
|
├── public/ # Static frontend (served by Express)
|
||||||
│ ├── index.html # HTML shell: splash, login, dashboard
|
│ ├── index.html # HTML shell: splash, login, dashboard
|
||||||
│ ├── app.js # All frontend logic (auth, rendering, status)
|
│ ├── app.js # All frontend logic (auth, rendering, status)
|
||||||
@@ -123,8 +151,21 @@ sofarr/
|
|||||||
│ ├── favicon-32.png # 32px PNG favicon
|
│ ├── favicon-32.png # 32px PNG favicon
|
||||||
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
||||||
│ └── images/ # Logo / splash screen assets
|
│ └── images/ # Logo / splash screen assets
|
||||||
├── Dockerfile # Production container image
|
├── tests/
|
||||||
|
│ ├── README.md # Testing approach, design decisions, coverage targets
|
||||||
|
│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass
|
||||||
|
│ ├── unit/ # Pure unit tests (no HTTP)
|
||||||
|
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md # This document
|
||||||
|
│ └── diagrams/ # PlantUML source files
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
||||||
|
│ ├── build-image.yml # Docker image build and push
|
||||||
|
│ └── create-release.yml # Release tagging workflow
|
||||||
|
├── Dockerfile # Multi-stage production container image (node:22-alpine)
|
||||||
├── docker-compose.yaml # Example compose deployment
|
├── docker-compose.yaml # Example compose deployment
|
||||||
|
├── vitest.config.js # Test runner configuration with per-file coverage thresholds
|
||||||
├── package.json # Dependencies and scripts
|
├── package.json # Dependencies and scripts
|
||||||
├── .env.sample # Annotated environment variable template
|
├── .env.sample # Annotated environment variable template
|
||||||
└── README.md # User-facing documentation
|
└── README.md # User-facing documentation
|
||||||
@@ -134,30 +175,45 @@ sofarr/
|
|||||||
|
|
||||||
## 4. Component Architecture
|
## 4. Component Architecture
|
||||||
|
|
||||||
### 4.1 Server Entry Point (`server/index.js`)
|
### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`)
|
||||||
|
|
||||||
Responsibilities:
|
**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller.
|
||||||
- Load environment variables via `dotenv`
|
|
||||||
- Configure structured logging with level filtering (`LOG_LEVEL`)
|
`createApp` responsibilities:
|
||||||
- Redirect `console.*` to both stdout and `server.log`
|
- Configure `trust proxy` from `TRUST_PROXY` env var
|
||||||
- Mount Express middleware (cookie-parser, JSON, static files)
|
- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts
|
||||||
- Mount route modules under `/api/*`
|
- Add `Permissions-Policy` header
|
||||||
|
- Apply the general API rate limiter (300 req / 15 min per IP)
|
||||||
|
- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set)
|
||||||
|
- Mount `express.json` (64 KB body limit)
|
||||||
|
- Expose `/health` and `/ready` endpoints (no auth, no rate limit)
|
||||||
|
- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt)
|
||||||
|
- Mount `verifyCsrf` for all subsequent `/api` routes
|
||||||
|
- Mount remaining route modules under `/api/*`
|
||||||
|
- Register global error handler (500 with sanitized message)
|
||||||
|
|
||||||
|
**`server/index.js`** entry point responsibilities:
|
||||||
|
- Load `.env` via `dotenv`
|
||||||
|
- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log`
|
||||||
|
- Call `createApp()`, serve `public/` as static files, start `app.listen()`
|
||||||
- Start the background poller
|
- Start the background poller
|
||||||
|
|
||||||
### 4.2 Route Modules
|
### 4.2 Route Modules
|
||||||
|
|
||||||
| Module | Mount Point | Auth Required | Purpose |
|
| Module | Mount Point | Auth Required | CSRF Required | Purpose |
|
||||||
|--------|------------|---------------|---------|
|
|--------|------------|:-------------:|:-------------:|--------|
|
||||||
| `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
|
| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout |
|
||||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
|
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy |
|
||||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
|
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API |
|
||||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
|
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API |
|
||||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API |
|
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API |
|
||||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API |
|
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API |
|
||||||
|
|
||||||
`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed.
|
**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid.
|
||||||
|
|
||||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
|
**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection).
|
||||||
|
|
||||||
|
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes.
|
||||||
|
|
||||||
### 4.3 Utility Modules
|
### 4.3 Utility Modules
|
||||||
|
|
||||||
@@ -167,9 +223,13 @@ Responsibilities:
|
|||||||
|
|
||||||
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
||||||
|
|
||||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
|
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities (`formatBytes`, `formatSpeed`, `formatEta`).
|
||||||
|
|
||||||
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
|
**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly.
|
||||||
|
|
||||||
|
**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs.
|
||||||
|
|
||||||
|
**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,12 +272,19 @@ When a user requests `/api/dashboard/user-downloads`:
|
|||||||
|
|
||||||
### Flow
|
### Flow
|
||||||
|
|
||||||
1. User submits credentials via the login form
|
1. User submits credentials (+ optional `rememberMe`) via the login form
|
||||||
2. Backend calls Emby `POST /Users/authenticatebyname`
|
2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count)
|
||||||
3. On success, fetches full user profile to determine admin status
|
3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login
|
||||||
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie
|
4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status
|
||||||
5. Cookie expires after 24 hours
|
5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client)
|
||||||
6. All subsequent dashboard requests read this cookie for identity
|
6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`:
|
||||||
|
- **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days
|
||||||
|
- **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes)
|
||||||
|
- `secure` flag enabled when `NODE_ENV=production`
|
||||||
|
- Signed with HMAC when `COOKIE_SECRET` is set
|
||||||
|
7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token
|
||||||
|
8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests
|
||||||
|
9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth`
|
||||||
|
|
||||||
### Authorisation Matrix
|
### Authorisation Matrix
|
||||||
|
|
||||||
@@ -336,47 +403,76 @@ Each matched download produces an object with:
|
|||||||
|
|
||||||
### `POST /api/auth/login`
|
### `POST /api/auth/login`
|
||||||
|
|
||||||
Authenticate a user via Emby.
|
Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**.
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
```json
|
```json
|
||||||
{ "username": "string", "password": "string" }
|
{ "username": "string", "password": "string", "rememberMe": false }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|:--------:|-----------|
|
||||||
|
| `username` | Yes | Max 128 chars, must be a non-empty string |
|
||||||
|
| `password` | Yes | Max 256 chars |
|
||||||
|
| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie |
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"user": { "id": "string", "name": "string", "isAdmin": true }
|
"user": { "id": "string", "name": "string", "isAdmin": false },
|
||||||
|
"csrfToken": "64-char hex string"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response (400):** Invalid input (empty/overlong username or password).
|
||||||
|
|
||||||
**Response (401):**
|
**Response (401):**
|
||||||
```json
|
```json
|
||||||
{ "success": false, "error": "Invalid username or password" }
|
{ "success": false, "error": "Invalid username or password" }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored.
|
**Response (429):** Too many failed attempts from this IP.
|
||||||
|
|
||||||
|
**Side Effects:**
|
||||||
|
- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included.
|
||||||
|
- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex.
|
||||||
|
- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /api/auth/me`
|
### `GET /api/auth/me`
|
||||||
|
|
||||||
Check current session.
|
Check current session (no auth required — returns unauthenticated state rather than 401).
|
||||||
|
|
||||||
**Response:**
|
**Response (authenticated):**
|
||||||
```json
|
```json
|
||||||
{
|
{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } }
|
||||||
"authenticated": true,
|
|
||||||
"user": { "id": "string", "name": "string", "isAdmin": false }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response (not authenticated):**
|
||||||
|
```json
|
||||||
|
{ "authenticated": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/auth/csrf`
|
||||||
|
|
||||||
|
Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{ "csrfToken": "64-char hex string" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Side Effect:** Sets a new `csrf_token` cookie.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `POST /api/auth/logout`
|
### `POST /api/auth/logout`
|
||||||
|
|
||||||
Clear session cookie.
|
Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -518,26 +614,47 @@ The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Of
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
|
#### Core
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|:--------:|---------|-------------|
|
|----------|:--------:|---------|-------------|
|
||||||
| `PORT` | No | `3001` | Server listen port |
|
| `PORT` | No | `3001` | Server listen port |
|
||||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
|
| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades |
|
||||||
| `EMBY_API_KEY` | Yes | — | Emby API key |
|
| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). |
|
||||||
|
| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. |
|
||||||
|
| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. |
|
||||||
|
|
||||||
|
#### Emby
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) |
|
||||||
|
| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) |
|
||||||
|
|
||||||
|
#### Service Instances
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
||||||
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
|
| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL |
|
||||||
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
|
| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key |
|
||||||
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
||||||
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
|
| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL |
|
||||||
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
|
| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key |
|
||||||
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
||||||
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
|
| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL |
|
||||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
|
| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key |
|
||||||
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
||||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
|
|
||||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
|
||||||
|
|
||||||
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
||||||
|
|
||||||
|
#### Tuning
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|:--------:|---------|-------------|
|
||||||
|
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). |
|
||||||
|
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||||
|
|
||||||
### Instance JSON Format
|
### Instance JSON Format
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -561,24 +678,20 @@ qBittorrent instances use `username` and `password` instead of `apiKey`.
|
|||||||
|
|
||||||
## 12. Deployment
|
## 12. Deployment
|
||||||
|
|
||||||
### Docker
|
### Docker image
|
||||||
|
|
||||||
```dockerfile
|
The production image uses a two-stage build on `node:22-alpine`:
|
||||||
FROM node:18-alpine
|
|
||||||
WORKDIR /app
|
1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies.
|
||||||
COPY package.json package-lock.json ./
|
2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs.
|
||||||
RUN npm ci --omit=dev
|
|
||||||
COPY server/ ./server/
|
Key environment variables set in the image:
|
||||||
COPY public/ ./public/
|
- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive
|
||||||
EXPOSE 3001
|
- `DATA_DIR=/app/data` — token store and log file location
|
||||||
ENV NODE_ENV=production
|
|
||||||
CMD ["node", "server/index.js"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
sofarr:
|
sofarr:
|
||||||
image: docker.i3omb.com/sofarr:latest
|
image: docker.i3omb.com/sofarr:latest
|
||||||
@@ -587,6 +700,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- COOKIE_SECRET=change-me-to-a-long-random-string
|
||||||
|
- TRUST_PROXY=1 # set if behind nginx/Traefik
|
||||||
- EMBY_URL=https://emby.example.com
|
- EMBY_URL=https://emby.example.com
|
||||||
- EMBY_API_KEY=your-emby-api-key
|
- EMBY_API_KEY=your-emby-api-key
|
||||||
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||||
@@ -595,8 +712,31 @@ services:
|
|||||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
||||||
- POLL_INTERVAL=5000
|
- POLL_INTERVAL=5000
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
|
volumes:
|
||||||
|
- sofarr-data:/app/data # persists tokens.json and server.log
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sofarr-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Security hardening checklist
|
||||||
|
|
||||||
|
- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery.
|
||||||
|
- **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires.
|
||||||
|
- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates.
|
||||||
|
- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`.
|
||||||
|
- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP.
|
||||||
|
|
||||||
|
### CI / CD
|
||||||
|
|
||||||
|
The `.gitea/workflows/` directory contains three pipeline definitions:
|
||||||
|
|
||||||
|
| File | Trigger | Purpose |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
||||||
|
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
|
||||||
|
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. UML Diagrams (PlantUML)
|
## 13. UML Diagrams (PlantUML)
|
||||||
|
|||||||
@@ -9,19 +9,33 @@ package "server/index.js" as entry {
|
|||||||
- logFile : WriteStream
|
- logFile : WriteStream
|
||||||
+ shouldLog(level) : boolean
|
+ shouldLog(level) : boolean
|
||||||
--
|
--
|
||||||
Configures Express app,
|
Logging setup, app.listen(),
|
||||||
mounts routes, starts poller
|
static files, startPoller()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
package "server/app.js" as appfactory {
|
||||||
|
class "createApp(options?)" as appfn <<factory>> {
|
||||||
|
+ createApp(skipRateLimits?) : Express
|
||||||
|
--
|
||||||
|
Mounts helmet (CSP nonce),
|
||||||
|
rate limiters, cookie-parser,
|
||||||
|
auth routes (pre-CSRF),
|
||||||
|
verifyCsrf, all other routes,
|
||||||
|
/health, /ready, error handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
package "server/routes" {
|
package "server/routes" {
|
||||||
class "auth.js" as auth <<router>> {
|
class "auth.js" as auth <<router>> {
|
||||||
+ POST /login
|
+ POST /login (rate-limited)
|
||||||
+ GET /me
|
+ GET /me
|
||||||
|
+ GET /csrf
|
||||||
+ POST /logout
|
+ POST /logout
|
||||||
--
|
--
|
||||||
Authenticates via Emby API
|
Authenticates via Emby API
|
||||||
Sets/reads httpOnly cookie
|
Issues emby_user + csrf_token cookies
|
||||||
|
Stores/revokes Emby tokens server-side
|
||||||
}
|
}
|
||||||
|
|
||||||
class "dashboard.js" as dashboard <<router>> {
|
class "dashboard.js" as dashboard <<router>> {
|
||||||
@@ -76,9 +90,20 @@ package "server/middleware" {
|
|||||||
class "requireAuth.js" as requireauth <<middleware>> {
|
class "requireAuth.js" as requireauth <<middleware>> {
|
||||||
+ requireAuth(req, res, next) : void
|
+ requireAuth(req, res, next) : void
|
||||||
--
|
--
|
||||||
Reads emby_user cookie
|
Reads emby_user cookie (signed if COOKIE_SECRET)
|
||||||
Attaches parsed user to req.user
|
Validates schema: id, name, isAdmin
|
||||||
Returns 401 if absent/invalid
|
Attaches user to req.user
|
||||||
|
Returns 401 if absent/tampered/invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
||||||
|
+ verifyCsrf(req, res, next) : void
|
||||||
|
--
|
||||||
|
Exempt: GET, HEAD, OPTIONS
|
||||||
|
Compares csrf_token cookie
|
||||||
|
vs X-CSRF-Token header
|
||||||
|
using crypto.timingSafeEqual
|
||||||
|
Returns 403 on mismatch/missing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +196,27 @@ package "server/utils" {
|
|||||||
+ logToFile(message) : void
|
+ logToFile(message) : void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class "TokenStore" as tokenstore <<module>> {
|
||||||
|
- store : Object (in-memory)
|
||||||
|
- STORE_PATH : string (DATA_DIR/tokens.json)
|
||||||
|
- TOKEN_TTL_MS : 31 days
|
||||||
|
--
|
||||||
|
+ storeToken(userId, accessToken) : void
|
||||||
|
+ getToken(userId) : {accessToken}|null
|
||||||
|
+ clearToken(userId) : void
|
||||||
|
--
|
||||||
|
Atomic write (.tmp → rename)
|
||||||
|
Pruned on startup + hourly
|
||||||
|
}
|
||||||
|
|
||||||
|
class "SanitizeError" as sanitize <<module>> {
|
||||||
|
+ sanitizeError(err) : string
|
||||||
|
--
|
||||||
|
Redacts: query-param secrets,
|
||||||
|
auth headers, bearer tokens,
|
||||||
|
basic-auth URLs
|
||||||
|
}
|
||||||
|
|
||||||
class "TagBadge" as tb <<value>> {
|
class "TagBadge" as tb <<value>> {
|
||||||
+ label : string
|
+ label : string
|
||||||
+ matchedUser : string | null
|
+ matchedUser : string | null
|
||||||
@@ -184,19 +230,24 @@ package "server/utils" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
' Relationships
|
' Relationships
|
||||||
ep --> auth
|
ep --> appfn : createApp()
|
||||||
ep --> dashboard
|
ep --> poller : startPoller()
|
||||||
ep --> emby_r
|
|
||||||
ep --> sab_r
|
appfn --> auth : /api/auth (pre-CSRF)
|
||||||
ep --> sonarr_r
|
appfn --> verifycsrf : /api (all routes below)
|
||||||
ep --> radarr_r
|
appfn --> dashboard
|
||||||
|
appfn --> emby_r
|
||||||
|
appfn --> sab_r
|
||||||
|
appfn --> sonarr_r
|
||||||
|
appfn --> radarr_r
|
||||||
|
|
||||||
dashboard --> requireauth : uses
|
dashboard --> requireauth : uses
|
||||||
emby_r --> requireauth : uses
|
emby_r --> requireauth : uses
|
||||||
sab_r --> requireauth : uses
|
sab_r --> requireauth : uses
|
||||||
sonarr_r --> requireauth : uses
|
sonarr_r --> requireauth : uses
|
||||||
radarr_r --> requireauth : uses
|
radarr_r --> requireauth : uses
|
||||||
ep --> poller : startPoller()
|
|
||||||
|
auth --> tokenstore : storeToken / getToken / clearToken
|
||||||
|
|
||||||
dashboard --> cache : read/write
|
dashboard --> cache : read/write
|
||||||
dashboard --> poller : pollAllServices()
|
dashboard --> poller : pollAllServices()
|
||||||
@@ -218,4 +269,7 @@ dashboard *-- ci : stores in activeClients
|
|||||||
|
|
||||||
config ..> inst : returns
|
config ..> inst : returns
|
||||||
|
|
||||||
|
auth ..> sanitize : sanitizeError on catch
|
||||||
|
dashboard ..> sanitize : sanitizeError on catch
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -15,15 +15,21 @@ package "Browser" as browser {
|
|||||||
|
|
||||||
package "Express Server" as server {
|
package "Express Server" as server {
|
||||||
|
|
||||||
|
[index.js\nEntry Point] as entry
|
||||||
|
[app.js\ncreatApp() factory] as appfactory
|
||||||
|
|
||||||
package "Middleware" {
|
package "Middleware" {
|
||||||
[cookie-parser] as cp
|
[helmet\n(CSP nonce, HSTS)] as hm
|
||||||
[express.json] as ej
|
[express-rate-limit\n(API + login)] as rl
|
||||||
|
[cookie-parser\n(signed cookies)] as cp
|
||||||
|
[express.json\n(64kb limit)] as ej
|
||||||
[express.static] as es
|
[express.static] as es
|
||||||
[requireAuth.js] as requireauth
|
[requireAuth.js] as requireauth
|
||||||
|
[verifyCsrf.js\n(double-submit)] as verifycsrf
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Routes" as routes {
|
package "Routes" as routes {
|
||||||
[auth.js\n/api/auth] as auth
|
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
||||||
[dashboard.js\n/api/dashboard] as dashboard
|
[dashboard.js\n/api/dashboard] as dashboard
|
||||||
[emby.js\n/api/emby] as emby_route
|
[emby.js\n/api/emby] as emby_route
|
||||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||||
@@ -36,27 +42,34 @@ package "Express Server" as server {
|
|||||||
[cache.js\nMemoryCache] as cache
|
[cache.js\nMemoryCache] as cache
|
||||||
[config.js] as config
|
[config.js] as config
|
||||||
[qbittorrent.js\nQBittorrentClient] as qbt
|
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||||
|
[tokenStore.js\n(tokens.json)] as tokenstore
|
||||||
|
[sanitizeError.js] as sanitize
|
||||||
[logger.js] as logger
|
[logger.js] as logger
|
||||||
}
|
}
|
||||||
|
|
||||||
[index.js\nEntry Point] as entry
|
entry --> appfactory : createApp()
|
||||||
|
entry --> es : serve public/
|
||||||
|
entry --> poller : startPoller()
|
||||||
|
|
||||||
entry --> cp
|
appfactory --> hm
|
||||||
entry --> ej
|
appfactory --> rl
|
||||||
entry --> es
|
appfactory --> cp
|
||||||
entry --> auth
|
appfactory --> ej
|
||||||
entry --> dashboard
|
appfactory --> auth : mount before verifyCsrf
|
||||||
entry --> emby_route
|
appfactory --> verifycsrf : applied to all /api below
|
||||||
entry --> sab_route
|
appfactory --> dashboard
|
||||||
entry --> sonarr_route
|
appfactory --> emby_route
|
||||||
entry --> radarr_route
|
appfactory --> sab_route
|
||||||
|
appfactory --> sonarr_route
|
||||||
|
appfactory --> radarr_route
|
||||||
|
|
||||||
emby_route --> requireauth
|
emby_route --> requireauth
|
||||||
sab_route --> requireauth
|
sab_route --> requireauth
|
||||||
sonarr_route --> requireauth
|
sonarr_route --> requireauth
|
||||||
radarr_route --> requireauth
|
radarr_route --> requireauth
|
||||||
dashboard --> requireauth
|
dashboard --> requireauth
|
||||||
entry --> poller : startPoller()
|
|
||||||
|
auth --> tokenstore : storeToken / getToken / clearToken
|
||||||
|
|
||||||
dashboard --> cache : read poll:* keys
|
dashboard --> cache : read poll:* keys
|
||||||
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||||
@@ -70,6 +83,9 @@ package "Express Server" as server {
|
|||||||
|
|
||||||
qbt --> config : getQbittorrentInstances()
|
qbt --> config : getQbittorrentInstances()
|
||||||
qbt --> logger
|
qbt --> logger
|
||||||
|
|
||||||
|
auth ..> sanitize
|
||||||
|
dashboard ..> sanitize
|
||||||
}
|
}
|
||||||
|
|
||||||
cloud "External Services" as external {
|
cloud "External Services" as external {
|
||||||
|
|||||||
+52
-15
@@ -5,6 +5,7 @@ title sofarr — Authentication Sequence
|
|||||||
actor User as user
|
actor User as user
|
||||||
participant "Browser\n(app.js)" as browser
|
participant "Browser\n(app.js)" as browser
|
||||||
participant "Express\n/api/auth" as auth
|
participant "Express\n/api/auth" as auth
|
||||||
|
participant "TokenStore\n(tokens.json)" as tokens
|
||||||
participant "Emby\nServer" as emby
|
participant "Emby\nServer" as emby
|
||||||
|
|
||||||
== Page Load ==
|
== Page Load ==
|
||||||
@@ -12,14 +13,19 @@ user -> browser : Navigate to sofarr
|
|||||||
activate browser
|
activate browser
|
||||||
browser -> auth : GET /api/auth/me
|
browser -> auth : GET /api/auth/me
|
||||||
activate auth
|
activate auth
|
||||||
auth -> auth : Read emby_user cookie
|
auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set)
|
||||||
alt Cookie exists and valid
|
alt Cookie exists and valid
|
||||||
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
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 : showDashboard()
|
||||||
browser -> browser : fetchUserDownloads(true)
|
|
||||||
browser -> browser : startAutoRefresh()
|
browser -> browser : startAutoRefresh()
|
||||||
browser -> browser : dismissSplash()
|
browser -> browser : dismissSplash()
|
||||||
else No cookie
|
else No cookie / tampered
|
||||||
auth --> browser : { authenticated: false }
|
auth --> browser : { authenticated: false }
|
||||||
browser -> browser : dismissSplash()
|
browser -> browser : dismissSplash()
|
||||||
browser -> browser : showLogin()
|
browser -> browser : showLogin()
|
||||||
@@ -27,38 +33,69 @@ end
|
|||||||
deactivate auth
|
deactivate auth
|
||||||
|
|
||||||
== Login ==
|
== Login ==
|
||||||
user -> browser : Enter username + password
|
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
||||||
browser -> auth : POST /api/auth/login\n{ username, password }
|
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
||||||
activate auth
|
activate auth
|
||||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
|
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
|
activate emby
|
||||||
alt Valid credentials
|
alt Valid credentials
|
||||||
emby --> auth : { User: { Id, ... }, AccessToken }
|
emby --> auth : { User: { Id }, AccessToken }
|
||||||
auth -> emby : GET /Users/{userId}
|
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
||||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||||
deactivate emby
|
deactivate emby
|
||||||
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored
|
auth -> tokens : storeToken(userId, AccessToken)
|
||||||
auth --> browser : { success: true, user: { name, isAdmin } }
|
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 : fadeOutLogin()
|
||||||
browser -> browser : showSplash()
|
|
||||||
browser -> browser : showDashboard()
|
browser -> browser : showDashboard()
|
||||||
browser -> browser : fetchUserDownloads(true)
|
|
||||||
browser -> browser : startAutoRefresh()
|
browser -> browser : startAutoRefresh()
|
||||||
browser -> browser : dismissSplash()
|
browser -> browser : dismissSplash()
|
||||||
else Invalid credentials
|
else Invalid credentials
|
||||||
emby --> auth : 401 Error
|
emby --> auth : 401 Error
|
||||||
deactivate emby
|
deactivate emby
|
||||||
auth --> browser : { success: false, error: "Invalid..." }
|
auth --> browser : { success: false, error: "Invalid username or password" }
|
||||||
browser -> browser : showLoginError()
|
browser -> browser : showLoginError()
|
||||||
end
|
end
|
||||||
deactivate auth
|
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 ==
|
== Logout ==
|
||||||
user -> browser : Click Logout
|
user -> browser : Click Logout
|
||||||
browser -> browser : stopAutoRefresh()
|
browser -> browser : stopAutoRefresh()
|
||||||
browser -> auth : POST /api/auth/logout
|
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
||||||
activate auth
|
activate auth
|
||||||
auth -> auth : Clear emby_user cookie
|
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 }
|
auth --> browser : { success: true }
|
||||||
deactivate auth
|
deactivate auth
|
||||||
browser -> browser : showLogin()
|
browser -> browser : showLogin()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Persistent SQLite-backed token store — survives restarts
|
// Persistent JSON file-backed token store — survives restarts
|
||||||
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
|
const { storeToken, getToken, clearToken } = require('../utils/tokenStore');
|
||||||
|
|
||||||
// Read EMBY_URL at request time (not module load time) so the value
|
// Read EMBY_URL at request time (not module load time) so the value
|
||||||
|
|||||||
Reference in New Issue
Block a user