diff --git a/src/servers/sidecar/photos/index.ts b/src/servers/sidecar/photos/index.ts index 5d4422f6..03ed4f5b 100644 --- a/src/servers/sidecar/photos/index.ts +++ b/src/servers/sidecar/photos/index.ts @@ -1,6 +1,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { handleConfigRoute, noteProbe, probe } from './config'; +import { handleLockedRoute } from './locked'; import { handleOfficerRoute } from './routes'; import { getConfig } from './upstream'; @@ -27,6 +28,13 @@ import { getConfig } from './upstream'; // POST /_config/:id/activate switch to that account // DEL /_config/:id remove it; the newest survivor is promoted if it was the active one // * /_officer/ forwarded to /api/, first-segment allow-list (routes.ts) +// +// GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state +// POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough) +// POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY +// POST /_locked/lock drops elevation and closes the session +// * /_locked/api/ forwarded on that session — assets, search and timeline only (locked.ts) +// // anything else 404 // // Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind @@ -114,6 +122,18 @@ const server = Bun.serve({ } } + if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) { + if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 }); + try { + return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length)); + } catch (err) { + // Never let the error text out: this surface handles a password and a PIN, and an upstream failure + // carrying either into a response body or a log line is the one leak worth being paranoid about. + console.error(`[photos] ${req.method} ${url.pathname} failed`); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + return Response.json({ error: 'not found' }, { status: 404 }); }, }); diff --git a/src/servers/sidecar/photos/locked.ts b/src/servers/sidecar/photos/locked.ts new file mode 100644 index 00000000..9f637ec6 --- /dev/null +++ b/src/servers/sidecar/photos/locked.ts @@ -0,0 +1,331 @@ +import type { UpstreamConfig } from './upstream'; +import { relayResponse } from './routes'; +import { UpstreamError, callUpstream, login, logout } from './upstream'; + +// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach. +// +// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts +// +// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running +// 3.1.0 image rather than inferred: +// +// utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw` +// services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'` +// services/session.service.js create → the same guard, so a key cannot even mint a session to escape this +// repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)` +// +// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key +// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape +// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and +// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings. +// +// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not +// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The +// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed +// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still +// does not open the private folder — the PIN and the password are never at rest here. +// +// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the +// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a +// standing security-posture change and this is not: everything below is forgotten when the process restarts. +// +// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it +// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the +// request asked for rather than a door left open for anything under `auth/`. + +/** What the API key alone can do, which is more than expected and worth being precise about. */ +const PIN_PATH = '/api/auth/pin-code'; + +/** + * Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror + * that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used + * for longer than the window can never be elevated again, so it is closed rather than left open indefinitely. + */ +const IDLE_MS = 20 * 60 * 1000; + +/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */ +const PIN_RE = /^\d{6}$/; + +type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number }; + +// Keyed by Officer user. The value never leaves this module and is never persisted. +const elevations = new Map(); + +/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */ +async function drop(userId: number): Promise { + const current = elevations.get(userId); + if (!current) return; + elevations.delete(userId); + await logout(current.base, current.token); +} + +/** + * The live session for this user, or null. + * + * Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was + * minted against, and serving the previous library's private folder after a switch would be the worst possible + * version of the caching bug the config routes already guard against. + */ +function current(userId: number, cfg: UpstreamConfig): Elevation | null { + const found = elevations.get(userId); + if (!found) return null; + + if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) { + void drop(userId); + return null; + } + return found; +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */ +const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 }); + +type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown }; + +const readBody = async (req: Request): Promise => ((await req.json().catch(() => null)) as Body | null) ?? {}; +const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); + +/** Immich answers `{ message }`; anything else means we never really reached it. */ +async function message(res: Response, fallback: string): Promise { + const body = (await res.json().catch(() => null)) as { message?: unknown } | null; + return typeof body?.message === 'string' ? body.message : fallback; +} + +/** + * The email to sign in as, taken from the key rather than from the caller. + * + * The client knows the password but has no reason to know which Immich account the stored key belongs to, and + * asking it to send an email would let a wrong one turn a valid password into "sign-in failed". + */ +async function ownerEmail(cfg: UpstreamConfig): Promise { + const res = await callUpstream(cfg, { path: '/api/users/me' }); + if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status); + + const me = (await res.json()) as { email?: unknown }; + if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500); + return me.email; +} + +/** + * `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked. + * + * Answered through the session when there is one, because only then does Immich report `isElevated` and + * `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session. + * `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than + * being handed a form that cannot work. + */ +async function status(cfg: UpstreamConfig, userId: number): Promise { + const session = current(userId, cfg); + const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token }); + if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status); + + const info = (await res.json()) as { + pinCode?: boolean; + password?: boolean; + isElevated?: boolean; + pinExpiresAt?: string; + }; + + // Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold. + if (session && !info.isElevated) await drop(userId); + + return Response.json({ + hasPin: !!info.pinCode, + hasPassword: !!info.password, + unlocked: !!info.isElevated, + expiresAt: info.pinExpiresAt ?? null, + }); +} + +/** + * `POST /_locked/unlock` — sign in, elevate, keep the session. + * + * A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one + * per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account. + */ +async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise { + const body = await readBody(req); + const password = typeof body.password === 'string' ? body.password : ''; + const pinCode = str(body.pinCode); + + if (!password) return bad('the Immich account password is required to unlock'); + if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); + + let token: string; + try { + token = await login(cfg.base, await ownerEmail(cfg), password); + } catch (err) { + if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400); + return bad(`could not reach the instance (${String(err)})`, 502); + } + + const res = await callUpstream(cfg, { + path: '/api/auth/session/unlock', + method: 'POST', + bearer: token, + body: JSON.stringify({ pinCode }), + contentType: 'application/json', + }); + if (!res.ok) { + await logout(cfg.base, token); + return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status); + } + + // Replace rather than accumulate: unlocking twice should not leave the first session open. + await drop(userId); + elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() }); + + return Response.json({ unlocked: true }); +} + +/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */ +async function lock(cfg: UpstreamConfig, userId: number): Promise { + const session = current(userId, cfg); + if (session) { + await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token }); + await drop(userId); + } + return Response.json({ unlocked: false }); +} + +/** + * `/_locked/pin` — set up, change and remove the PIN, all on the API key. + * + * These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never + * touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder + * something Officer can offer end to end instead of sending the owner to Immich's own settings screen first. + */ +async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise { + const body = await readBody(req); + const pinCode = str(body.pinCode); + const newPinCode = str(body.newPinCode); + const password = typeof body.password === 'string' ? body.password : ''; + + const call = (method: string, payload: Record) => + callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' }); + + if (req.method === 'POST') { + if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits'); + const res = await call('POST', { pinCode }); + return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status); + } + + // Change and reset both prove intent with the OLD pin or the account password, so the two are read together. + const proof: Record | null = password ? { password } : pinCode ? { pinCode } : null; + if (!proof) return bad('the current PIN, or the account password, is required'); + + if (req.method === 'PUT') { + if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits'); + const res = await call('PUT', { ...proof, newPinCode }); + return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status); + } + + const res = await call('DELETE', proof); + if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status); + + // Immich drops elevation from every session when the PIN is reset; our copy has to go with it. + await drop(userId); + return Response.json({ hasPin: false, unlocked: false }); +} + +/** + * The elevated forward, deliberately a fraction of the main allow-list. + * + * Three resources, because three are enough to see the folder and move things in and out of it: read the + * assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the + * trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an + * irreversible route is not worth putting behind a credential the owner cannot see. + */ +const ELEVATED: Record = { + assets: ['GET', 'PUT'], + search: ['POST'], + timeline: ['GET'], +}; + +/** `/_locked/api/` — the same shape as `/_officer/`, but on the session and far narrower. */ +async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise { + // The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will + // never carry has to say so whether or not the folder happens to be open — answering 423 would invite a + // client to prompt for a password and a PIN to reach something that stays a 404 afterwards. + const [resource] = rest.split('/'); + const allowed = resource ? ELEVATED[resource] : undefined; + if (!allowed) return Response.json({ error: 'not found' }, { status: 404 }); + if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405); + + const session = current(userId, cfg); + if (!session) return locked(); + + // Officer's own JWT arrives in `?token=` because an cannot send a header. Forwarding it would write the + // owner's session credential into Immich's access log for every locked thumbnail. + const query = new URLSearchParams(url.search); + query.delete('token'); + const search = query.toString(); + + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + const res = await callUpstream(cfg, { + path: `/api/${rest}`, + method: req.method, + query: search ? `?${search}` : '', + body: hasBody ? await req.arrayBuffer() : null, + contentType: req.headers.get('content-type'), + range: req.headers.get('range'), + ifNoneMatch: req.headers.get('if-none-match'), + bearer: session.token, + }); + + // The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error. + if (res.status === 401) { + await drop(userId); + return locked('the private folder locked itself again'); + } + + session.lastUsedAt = Date.now(); + return relayResponse(res); +} + +/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets//thumbnail'. */ +export async function handleLockedRoute( + req: Request, + cfg: UpstreamConfig, + userId: number, + subpath: string, +): Promise { + const url = new URL(req.url); + const [, head, ...tail] = subpath.split('/'); + + if (head === 'api') return forward(req, url, cfg, userId, tail.join('/')); + + if (head === 'status') { + return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405); + } + + if (head === 'unlock') { + return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405); + } + + if (head === 'lock') { + return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405); + } + + if (head === 'pin') { + const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; + return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405); + } + + return Response.json({ error: 'not found' }, { status: 404 }); +} + +/** + * Close sessions nobody came back for. + * + * Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account + * until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there. + */ +setInterval(() => { + const now = Date.now(); + for (const [userId, session] of elevations) { + if (now - session.lastUsedAt > IDLE_MS) void drop(userId); + } +}, 60_000).unref(); diff --git a/src/servers/sidecar/photos/routes.ts b/src/servers/sidecar/photos/routes.ts index dd359cad..604eaa17 100644 --- a/src/servers/sidecar/photos/routes.ts +++ b/src/servers/sidecar/photos/routes.ts @@ -13,6 +13,12 @@ import { callUpstream } from './upstream'; // DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and // password machinery — the key is the credential here, and nothing should be minting sessions through a // dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/* +// +// `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth +// routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached +// through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a +// resource anything may address. The distinction is the point: the decision that was taken was "the sidecar +// may unlock the private folder", not "the sidecar may reach session and password machinery". // (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external // library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it. // Adding one should be a decision, not an accident. @@ -60,6 +66,23 @@ const PASSTHROUGH_HEADERS = [ 'cache-control', ] as const; +/** + * Hand one Immich response back to the caller, keeping only the headers above. + * + * Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a + * thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is. + */ +export function relayResponse(res: Response): Response { + const headers = new Headers(); + for (const name of PASSTHROUGH_HEADERS) { + const value = res.headers.get(name); + if (value) headers.set(name, value); + } + + const body = res.status === 204 || res.status === 304 ? null : res.body; + return new Response(body, { status: res.status, headers }); +} + /** * Forward one request to Immich and stream the answer back. * @@ -96,13 +119,5 @@ export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: ifNoneMatch: req.headers.get('if-none-match'), }); - const headers = new Headers(); - for (const name of PASSTHROUGH_HEADERS) { - const value = res.headers.get(name); - if (value) headers.set(name, value); - } - - // 204 and 304 must not carry a body, and Bun throws if one is attached. - const body = res.status === 204 || res.status === 304 ? null : res.body; - return new Response(body, { status: res.status, headers }); + return relayResponse(res); } diff --git a/src/servers/sidecar/photos/upstream.ts b/src/servers/sidecar/photos/upstream.ts index 9d92f39e..98e38dde 100644 --- a/src/servers/sidecar/photos/upstream.ts +++ b/src/servers/sidecar/photos/upstream.ts @@ -79,25 +79,19 @@ async function upstreamMessage(res: Response, fallback: string): Promise } /** - * Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own - * settings screen. + * Trade an email and password for a SESSION token. * - * Three calls, and the shape of them is the point: + * A session is not an API key and the difference is load-bearing in both directions: sessions are listed as + * devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the + * `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate. + * `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against + * the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to + * get there. That is why this is exported rather than inlined into mintApiKey. * - * 1. `/auth/login` returns a SESSION token, not an API key. Storing that would be wrong — sessions are - * listed as devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather - * than the `x-api-key` header everything else here uses. - * 2. `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only - * grantable because a session has every permission — an API key can never mint a key stronger than - * itself (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys - * are scoped, and an under-scoped one returns 403 per route, which reads as a broken feature. - * 3. `/auth/logout` ends the session we only needed to hold the key. Skipping it would leave a phantom - * "device" in the owner's Immich account for every connection made from Officer. - * - * The password is used here and nowhere else; only the key survives this function. + * The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it. */ -export async function mintApiKey(base: string, email: string, password: string): Promise { - const login = await fetch(`${base}/api/auth/login`, { +export async function login(base: string, email: string, password: string): Promise { + const res = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), @@ -105,13 +99,43 @@ export async function mintApiKey(base: string, email: string, password: string): }); // Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus // "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them. - if (!login.ok) throw new UpstreamError(await upstreamMessage(login, 'sign-in failed'), login.status); + if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status); - const session = (await login.json()) as { accessToken?: unknown }; + const session = (await res.json()) as { accessToken?: unknown }; if (typeof session.accessToken !== 'string' || !session.accessToken) { - throw new UpstreamError('sign-in returned no access token', login.status); + throw new UpstreamError('sign-in returned no access token', res.status); } - const bearer = { Authorization: `Bearer ${session.accessToken}` }; + return session.accessToken; +} + +/** + * End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the + * caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich + * account for every sign-in Officer makes. + */ +export async function logout(base: string, token: string): Promise { + await fetch(`${base}/api/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + redirect: 'manual', + }).catch(() => {}); +} + +/** + * Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own + * settings screen. + * + * `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only + * grantable because a session has every permission — an API key can never mint a key stronger than itself + * (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped, + * and an under-scoped one returns 403 per route, which reads as a broken feature. + * + * The session exists only to hold the key and is closed in the `finally`. The password is used here and + * nowhere else; only the key survives this function. + */ +export async function mintApiKey(base: string, email: string, password: string): Promise { + const accessToken = await login(base, email, password); + const bearer = { Authorization: `Bearer ${accessToken}` }; try { const created = await fetch(`${base}/api/api-keys`, { @@ -130,8 +154,7 @@ export async function mintApiKey(base: string, email: string, password: string): } return payload.secret; } finally { - // Best effort: a session we cannot close is untidy, not a failure of the thing the owner asked for. - await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: bearer, redirect: 'manual' }).catch(() => {}); + await logout(base, accessToken); } } @@ -148,13 +171,23 @@ type CallOptions = { ifNoneMatch?: string | null; /** Set false for the version/ping routes, which Immich serves unauthenticated. */ withKey?: boolean; + /** + * A session token to authenticate as, INSTEAD of the stored API key. + * + * Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that + * must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are + * mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its + * header precedence rather than to this file. + */ + bearer?: string; }; /** The single door to Immich. Everything the sidecar fetches goes through here. */ export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { const headers: Record = { Accept: 'application/json' }; - if (opts.withKey !== false) headers['x-api-key'] = cfg.key; + if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`; + else if (opts.withKey !== false) headers['x-api-key'] = cfg.key; if (opts.contentType) headers['Content-Type'] = opts.contentType; if (opts.range) headers.Range = opts.range; if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;