diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 1777b9be85..3419dbbabe 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -37,6 +37,8 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. +**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie HttpOnly, SameSite=Strict, limité à cet hôte et Secure en HTTPS. Aucun jeton administrateur n’est stocké dans localStorage ou sessionStorage. Le fichier privé dashboard-sessions.json conserve uniquement les empreintes du cookie, du jeton et du navigateur ainsi qu’une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. **Oublier cet appareil** dans les actions mobiles ou la barre latérale, ainsi que la déconnexion, révoque cet appareil et toutes ses sessions. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage impose une nouvelle connexion. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge cette option. Les anciennes clés de jeton mémorisé sont supprimées sans lecture ni migration. Réservez-la aux appareils de confiance ; elle ne rétablit pas l’AutoFill de Safari. + ## Barre de résumé des quotas Une ligne de résumé en haut de chaque page, sauf la page Sécurité au démarrage, indique diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index d808779cd3..53b00f7289 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -35,6 +35,8 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. +**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie. HTTPS cookies are Secure and host-only. The browser never stores the admin token in localStorage or sessionStorage. The server stores only a cookie hash, an admin-credential fingerprint, browser identity hash and a fixed 30-day expiry in its private dashboard-sessions.json file. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. **Forget this device** in the sidebar or mobile action bar (and session logout) revokes the record and all sessions resumed from it. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. Cross-origin/relayed dashboards and remote plaintext HTTP do not support this option. Older remembered bearer-token keys are removed without being read or migrated. Use only on a device you trust. This does not make Safari offer password AutoFill. + ### Finding the admin token You only need this on a non-loopback bind. A local dashboard never asks, and if a local one diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index bc1e933f30..3b59f7d7a4 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 +**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict Cookie に交換します。HTTPS では Secure とホスト限定が適用されます。管理者トークンを localStorage や sessionStorage に保存しません。非公開の dashboard-sessions.json には Cookie・認証情報・ブラウザーのハッシュと固定30日間の有効期限だけを保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。サイドバーやモバイル操作の **このデバイスを解除**、またはログアウトで記録と派生セッションを失効させます。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。中継・別オリジン・リモート HTTP では使えません。旧トークン保存キーは読み取りや移行をせず削除します。信頼できるデバイスのみで使用してください。Safari の自動入力を有効にするものではありません。 + ## クォータ概要バー 起動安全性ページを除くすべてのページ上部の 1 行の概要に、各プロバイダーの現在のクォータ使用率が表示されます。例: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。プロバイダー画面と同じクォータレポート(`GET /api/provider-quotas`、タブが表示されている間は 60 秒ごと)を読み取り、上流への強制更新は行いません。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index d014bf6f19..585aa97ed3 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. +**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 쿠키로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 비공개 dashboard-sessions.json에는 쿠키·인증 정보·브라우저 해시와 고정 30일 만료만 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 사이드바나 모바일 작업의 **이 기기 잊기** 및 로그아웃은 기기 기록과 모든 파생 세션을 취소합니다. 만료, 브라우저 식별 변경, 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. 릴레이·다른 출처·원격 HTTP에서는 지원하지 않습니다. 이전 토큰 저장 키는 읽거나 마이그레이션하지 않고 삭제합니다. 신뢰하는 기기에서만 사용하세요. Safari 자동 완성을 복원하는 기능은 아닙니다. + ## 사용량 요약 바 시작 안전성 페이지를 제외한 모든 페이지 상단의 한 줄 요약에 프로바이더별 현재 할당량 사용률이 표시됩니다. 예: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. 프로바이더 작업 화면과 같은 할당량 보고(`GET /api/provider-quotas`)를 탭이 보이는 동안 60초마다 읽으며, 업스트림 강제 새로고침은 하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index bb44e1ee57..db5fb56f89 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -27,6 +27,8 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. +**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер заменяет токен администратора на HttpOnly, SameSite=Strict cookie устройства; в HTTPS cookie имеет Secure и привязку к хосту. Токен не записывается в localStorage или sessionStorage. Приватный dashboard-sessions.json хранит только хеши cookie, учётных данных и браузера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. **Забыть это устройство** в боковой панели или мобильных действиях, как и выход, отзывает запись и все связанные сессии. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. Релейный, междоменный и удалённый HTTP-доступ не поддерживаются. Старые ключи сохранённых токенов удаляются без чтения или миграции. Используйте только доверенное устройство; AutoFill Safari это не восстанавливает. + ## Полоса сводки квот Одна строка в верхней части каждой страницы, кроме страницы «Безопасность запуска», показывает текущее использование квот каждым провайдером, например `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. Она читает те же отчёты о квотах провайдеров, что и рабочая область провайдера (`GET /api/provider-quotas`, каждые 60 секунд, пока вкладка видима), и никогда не форсирует обновление на стороне провайдера. diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 3fc797454c..619890f756 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -39,6 +39,8 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. +**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çereziyle değiştirilir; HTTPS çerezi Secure ve yalnızca bu sunucuya özeldir. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Özel dashboard-sessions.json yalnızca çerez, kimlik bilgisi ve tarayıcı özetleriyle sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Kenar çubuğu veya mobil işlemlerde **Bu cihazı unut** ve oturumu kapatma, kaydı ve türetilen tüm oturumları iptal eder. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. Relay, başka origin ve uzak düz HTTP desteklenmez. Eski belirteç anahtarları okunmadan veya taşınmadan silinir. Yalnızca güvenilir cihazda kullanın; Safari AutoFill özelliğini geri getirmez. + ## Kota özeti çubuğu Başlangıç güvenliği sayfası dışındaki her sayfanın üst kısmındaki tek satırlık özet, her diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 044fd71f38..c4a511be3b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,6 +26,8 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 +**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie;HTTPS 下启用 Secure,且仅限本主机。管理员令牌不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 仅保存 Cookie、凭据和浏览器哈希及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。侧栏或移动操作中的 **忘记此设备** 以及退出登录,会撤销该记录及其所有会话。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。中继、跨来源和远程明文 HTTP 不支持此选项。旧令牌存储键会直接删除,不读取或迁移。仅在可信设备启用;不会恢复 Safari 密码自动填充。 + ## 配额摘要栏 除启动安全页面外,每个页面顶部的一行摘要会显示各 provider 当前的配额用量,例如 `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。它读取与提供方工作区相同的配额报告(`GET /api/provider-quotas`,标签页可见时每 60 秒一次),并且绝不会强制刷新上游。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 9128bf87e4..5f2230ebe6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,6 +31,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 +**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie;HTTPS 下使用 Secure,且僅限本主機。管理員權杖不存入 localStorage 或 sessionStorage。私有 dashboard-sessions.json 僅保存 Cookie、憑證和瀏覽器雜湊及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。側欄或行動操作中的 **忘記此裝置** 以及登出,會撤銷記錄及其所有工作階段。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。中繼、跨來源和遠端明文 HTTP 不支援此選項。舊權杖儲存鍵會直接刪除,不讀取或遷移。僅在可信裝置啟用;不會恢復 Safari 密碼自動填入。 + ## 配額摘要列 除啟動安全頁面外,每個頁面頂端的一行摘要會顯示各供應商目前的配額用量,例如 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 99fa327d6b..7876b14bb2 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -22,7 +22,7 @@ import { NAV_GROUPS, groupForPage, visibleGroupPages } from "./nav-groups"; import { SectionSwitcher } from "./components/SectionSwitcher"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; -import { bootstrapApiSession, configureApiTargets, hasApiSession, installApiAuthFetch, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; +import { bootstrapApiSession, canRememberDashboardDevice, configureApiTargets, hasApiSession, installApiAuthFetch, logoutApiSession, SESSION_CHANGED_EVENT, SESSION_UNAVAILABLE_EVENT } from "./api"; import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, runtimeRoleFromDocument, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { validateRemoteLinkSession } from "./connect-pairing-transport"; @@ -159,12 +159,13 @@ export default function App() { useEffect(() => { const unavailable = (event: Event) => { - if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared" && !hasApiSession("shared")) { - setSharedSessionReady(false); + if ((event as CustomEvent<{ plane?: string }>).detail?.plane === "shared") { + setSharedSessionReady(hasApiSession("shared")); } }; window.addEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); - return () => window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); + window.addEventListener(SESSION_CHANGED_EVENT, unavailable); + return () => { window.removeEventListener(SESSION_UNAVAILABLE_EVENT, unavailable); window.removeEventListener(SESSION_CHANGED_EVENT, unavailable); }; }, []); useEffect(() => { @@ -359,8 +360,10 @@ export default function App() { const loggedOut = await logoutApiSession("shared"); setSessionLoggingOut(false); if (loggedOut) setSharedSessionReady(false); - else report(t("connection.sessionLogoutFailed"), "err"); + else report(t(canRememberDashboardDevice("shared") ? "connection.forgetDeviceFailed" : "connection.sessionLogoutFailed"), "err"); }; + const deviceForgetAvailable = canRememberDashboardDevice("shared"); + const endSessionLabel = t(deviceForgetAvailable ? "connection.forgetDevice" : "connection.sessionLogout"); /* * The brand is the control users reach for first when they want out of a deep page, @@ -413,9 +416,9 @@ export default function App() { {brand}
- {targets.connected && sharedSessionReady && ( + {(targets.connected || deviceForgetAvailable) && sharedSessionReady && ( )} @@ -507,10 +510,10 @@ export default function App() {
{t("dash.actions")}
- {targets.connected && sharedSessionReady && ( + {(targets.connected || deviceForgetAvailable) && sharedSessionReady && ( )} diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index cb383fe7d6..539ca981b5 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -5,16 +5,17 @@ const ADMIN_TOKEN_USERNAME = "OpenCodex"; const ADMIN_TOKEN_DOCS_URL = "https://opencodex.me/guides/web-dashboard/#finding-the-admin-token"; export type AdminTokenValidation = "accepted" | "rejected" | "unavailable"; -export type AdminTokenVerifier = (token: string) => Promise; +export type AdminTokenVerifier = (token: string, rememberDevice?: boolean) => Promise; /** * Ask for the management credential with a real sign-in form so browsers and * password managers can offer save/autofill. OpenCodex itself still keeps the - * submitted token in memory only; persistence remains entirely browser-owned. + * submitted token in memory only; Remember requests an HttpOnly server session. */ export function promptForAdminToken( verifyToken: AdminTokenVerifier, locale: Locale = getActiveLocale(), + rememberAllowed = true, ): Promise { const messages = DICTS[locale]; const titleText = messages["auth.adminTokenTitle"]; @@ -91,6 +92,18 @@ export function promptForAdminToken( help.append(" ", docsLink); tokenField.append(help); + const rememberLabel = document.createElement("label"); + rememberLabel.className = "field-label"; + const remember = document.createElement("input"); + remember.type = "checkbox"; + remember.name = "remember-device"; + remember.disabled = !rememberAllowed; + rememberLabel.append(remember, " ", messages["auth.rememberDevice"]); + const rememberHint = document.createElement("p"); + rememberHint.className = "hint"; + rememberHint.textContent = messages["auth.rememberDeviceHint"]; + tokenField.append(rememberLabel, rememberHint); + const validationError = document.createElement("div"); validationError.className = "notice notice-err"; validationError.setAttribute("role", "alert"); @@ -142,9 +155,10 @@ export function promptForAdminToken( } password.disabled = true; submit.disabled = true; + remember.disabled = true; setValidationError(null); - void verifyToken(token).then((result) => { + void verifyToken(token, rememberAllowed && remember.checked).then((result) => { if (settled) return; if (result === "accepted") { finish(token); @@ -153,6 +167,7 @@ export function promptForAdminToken( password.value = ""; password.disabled = false; submit.disabled = false; + remember.disabled = !rememberAllowed; setValidationError(result === "rejected" ? messages["auth.adminTokenRejected"] : messages["auth.adminTokenUnavailable"]); @@ -162,6 +177,7 @@ export function promptForAdminToken( password.value = ""; password.disabled = false; submit.disabled = false; + remember.disabled = !rememberAllowed; setValidationError(messages["auth.adminTokenUnavailable"]); password.focus(); }); diff --git a/gui/src/api.ts b/gui/src/api.ts index eafa786617..c259b486a3 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -41,13 +41,14 @@ interface SessionRevision { state: TargetRuntime; generation: object } interface SessionSnapshot extends SessionRevision { token: string | null } interface RecoveryAttempt extends SessionRevision { plane: ApiPlane; active: boolean } -type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; +type AdminTokenPrompt = (verifyToken: AdminTokenVerifier, rememberAllowed?: boolean) => Promise; +const defaultAdminTokenPrompt: AdminTokenPrompt = (verify, allowed) => promptForAdminToken(verify, undefined, allowed); type RebootstrapResult = { kind: "minted"; token: string } | { kind: "unavailable" } | { kind: "failed" }; let installed = false; let rawFetch: typeof fetch | null = null; let configuredTargets: ApiTargets | null = null; -let requestAdminToken: AdminTokenPrompt = promptForAdminToken; +let requestAdminToken: AdminTokenPrompt = defaultAdminTokenPrompt; let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; const runtimes = new Map(); @@ -290,6 +291,19 @@ export function fetchApiSessionValidation(plane: ApiPlane, input: RequestInfo | function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); } catch { /* storage may be disabled */ } + try { + const storage = window.localStorage; + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key === "opencodex.remembered-admin-token" || key?.startsWith("opencodex.remembered-admin-token:")) storage.removeItem(key); + } + } catch { /* do not read or migrate old bearer tokens */ } +} + +export function canRememberDashboardDevice(plane: ApiPlane): boolean { + const target = runtime(plane).target; + return target.transport !== "relay" && target.serverOrigin === window.location.origin + && (window.location.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname)); } function targetAbsoluteBase(target: ApiTarget): URL { @@ -381,14 +395,26 @@ export async function bootstrapApiSession(plane: ApiPlane, signal?: AbortSignal) finally { recovery.active = false; } } -async function verifyAdminToken(plane: ApiPlane, token: string): ReturnType { +async function verifyAdminToken(recovery: RecoveryAttempt, token: string, remember = false): ReturnType { if (!rawFetch) return "unavailable"; + const { plane, state } = recovery; + if (remember && !canRememberDashboardDevice(plane)) return "unavailable"; try { - const state = runtime(plane); - const [input, init] = withAuth(plane, `${state.target.baseUrl}${ADMIN_TOKEN_VALIDATION_PATH}`, { cache: "no-store" }, token); - const response = await rawFetch(input, init); - if (response.status === 401) return "rejected"; - return response.ok ? "accepted" : "unavailable"; + return await withFetchDeadline(rebootstrapTimeoutMs, undefined, async signal => { + const path = remember ? "/api/session/remember" : ADMIN_TOKEN_VALIDATION_PATH; + const [input, init] = withAuth(plane, state.target.baseUrl + path, + { method: remember ? "POST" : "GET", cache: "no-store", signal, credentials: remember ? "same-origin" : "omit", redirect: "error" }, token); + const response = await rawFetch!(input, init); + if (signal.aborted || !isCurrentRecovery(recovery)) return "unavailable"; + if (response.status === 401) return "rejected"; + if (!response.ok) return "unavailable"; + if (remember) { + const body = await response.json(); + if (signal.aborted || !isCurrentRecovery(recovery) || !storeSession(plane, body?.session?.token, + body?.session?.csrfToken, body?.session?.browserOrigin, body?.session?.serverOrigin, recovery)) return "unavailable"; + } + return "accepted"; + }); } catch { return "unavailable"; } } @@ -421,13 +447,14 @@ async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, state.promptCancelled = true; return sessionSnapshot(state); } - const prompted = await requestAdminToken(async token => { + const prompted = await requestAdminToken(async (token, remember) => { if (!isCurrentRecovery(recovery)) return "unavailable"; - const result = await verifyAdminToken(plane, token); + const result = await verifyAdminToken(recovery, token, remember); return isCurrentRecovery(recovery) ? result : "unavailable"; - }); + }, canRememberDashboardDevice(plane)); if (!isCurrentRecovery(recovery)) return null; if (prompted) { + if (state.session.token?.startsWith("ocx_session_")) return sessionSnapshot(state); updateSession(state, { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }, recovery); return isCurrentRecovery(recovery) ? sessionSnapshot(state) : null; } @@ -512,7 +539,7 @@ export function fetchAudioUpload(endpoint: string, init: RequestInit): Promise = { "auth.adminTokenRejected": "Der Admin-Token wurde abgelehnt. Prüfen Sie ihn und versuchen Sie es erneut.", "auth.adminTokenUnavailable": "Der Admin-Token konnte nicht überprüft werden. Versuchen Sie es erneut.", "auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.", + "auth.rememberDevice": "Dieses Gerät merken", + "auth.rememberDeviceHint": "Eine widerrufbare HttpOnly-Dashboard-Sitzung 30 Tage behalten, nicht das Admin-Token. Erfordert ein direktes HTTPS-Dashboard (oder lokalen Loopback).", + "connection.forgetDevice": "Dieses Gerät vergessen", + "connection.forgetDeviceFailed": "Dieses Gerät konnte nicht vergessen werden. Die Sitzung wurde beibehalten.", "auth.adminTokenDocsLink": "So finden Sie ihn", "theme.label": "Design", "theme.light": "Hell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bff6fef8e5..3465a1763e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -187,6 +187,10 @@ export const en = { "auth.adminTokenRejected": "That admin token was rejected. Check it and try again.", "auth.adminTokenUnavailable": "The admin token could not be verified. Try again.", "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", + "auth.rememberDevice": "Remember this device", + "auth.rememberDeviceHint": "Keep a revocable HttpOnly dashboard session for 30 days, not the admin token. Requires a direct HTTPS dashboard (or local loopback).", + "connection.forgetDevice": "Forget this device", + "connection.forgetDeviceFailed": "Could not forget this device. The session was kept.", "auth.adminTokenDocsLink": "How to find it", "app.logoAria": "opencodex logo", "app.claudeOn": "Claude ON", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5681f60e2d..01f3916bba 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -184,6 +184,10 @@ export const fr: Record = { "auth.adminTokenRejected": "Ce jeton d’administration a été refusé. Vérifiez-le et réessayez.", "auth.adminTokenUnavailable": "Le jeton d’administration n’a pas pu être vérifié. Réessayez.", "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", + "auth.rememberDevice": "Mémoriser cet appareil", + "auth.rememberDeviceHint": "Conserve une session HttpOnly révocable pendant 30 jours, pas le jeton administrateur. Nécessite un tableau de bord HTTPS direct (ou une boucle locale).", + "connection.forgetDevice": "Oublier cet appareil", + "connection.forgetDeviceFailed": "Impossible d’oublier cet appareil. La session a été conservée.", "auth.adminTokenDocsLink": "Où le trouver", "app.logoAria": "Logo opencodex", "app.claudeOn": "Claude ACTIVÉ", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce6e00d9c7..e5c3f36abb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -254,6 +254,10 @@ export const ja: Record = { "auth.adminTokenRejected": "管理者トークンが拒否されました。確認してもう一度お試しください。", "auth.adminTokenUnavailable": "管理者トークンを確認できませんでした。もう一度お試しください。", "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", + "auth.rememberDevice": "このデバイスを記憶する", + "auth.rememberDeviceHint": "管理者トークンではなく、取り消し可能な HttpOnly セッションを30日間保持します。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)が必要です。", + "connection.forgetDevice": "このデバイスを解除", + "connection.forgetDeviceFailed": "デバイスを解除できませんでした。セッションは保持されています。", "auth.adminTokenDocsLink": "確認方法", "app.logoAria": "opencodex ロゴ", "app.claudeOn": "Claude オン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1e1e77d9f..f8550084f6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -252,6 +252,10 @@ export const ko: Record = { "auth.adminTokenRejected": "관리자 토큰이 거부되었습니다. 확인한 후 다시 시도하세요.", "auth.adminTokenUnavailable": "관리자 토큰을 확인할 수 없습니다. 다시 시도하세요.", "auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.", + "auth.rememberDevice": "이 기기 기억하기", + "auth.rememberDeviceHint": "관리자 토큰이 아닌 취소 가능한 HttpOnly 세션을 30일 동안 유지합니다. 직접 연결된 HTTPS 대시보드(또는 로컬 루프백)가 필요합니다.", + "connection.forgetDevice": "이 기기 잊기", + "connection.forgetDeviceFailed": "이 기기를 해제하지 못했습니다. 세션은 유지됩니다.", "auth.adminTokenDocsLink": "찾는 방법", "theme.label": "테마", "theme.light": "라이트", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cca8aaa696..f36ef75e43 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -209,6 +209,10 @@ export const pt: Record = { "auth.adminTokenRejected": "Esse token de administrador foi rejeitado. Confira-o e tente novamente.", "auth.adminTokenUnavailable": "Não foi possível verificar o token de administrador. Tente novamente.", "auth.adminTokenHelp": "Este é o token de administrador de gerenciamento do OpenCodex, não uma chave de API de provedor. O proxy o grava em ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token) na primeira inicialização, e OPENCODEX_ADMIN_AUTH_TOKEN o substitui.", + "auth.rememberDevice": "Lembrar este dispositivo", + "auth.rememberDeviceHint": "Mantém uma sessão HttpOnly revogável por 30 dias, não o token de administrador. Requer um painel HTTPS direto (ou loopback local).", + "connection.forgetDevice": "Esquecer este dispositivo", + "connection.forgetDeviceFailed": "Não foi possível esquecer este dispositivo. A sessão foi mantida.", "auth.adminTokenDocsLink": "Como encontrá-lo", "app.logoAria": "logotipo do opencodex", "app.claudeOn": "Claude ON", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2709713c0b..93bf4b0141 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -254,6 +254,10 @@ export const ru: Record = { "auth.adminTokenRejected": "Токен администратора отклонён. Проверьте его и повторите попытку.", "auth.adminTokenUnavailable": "Не удалось проверить токен администратора. Повторите попытку.", "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", + "auth.rememberDevice": "Запомнить это устройство", + "auth.rememberDeviceHint": "Сохраняет отзываемую HttpOnly-сессию на 30 дней, а не токен администратора. Требуется прямой доступ к панели по HTTPS (или локальный loopback).", + "connection.forgetDevice": "Забыть это устройство", + "connection.forgetDeviceFailed": "Не удалось забыть это устройство. Сессия сохранена.", "auth.adminTokenDocsLink": "Как его найти", "app.logoAria": "Логотип opencodex", "app.claudeOn": "Claude ВКЛ", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 154d0356c5..50aa22626a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -186,6 +186,10 @@ export const tr: Record = { "auth.adminTokenRejected": "Bu yönetici jetonu reddedildi. Kontrol edip tekrar deneyin.", "auth.adminTokenUnavailable": "Yönetici jetonu doğrulanamadı. Tekrar deneyin.", "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", + "auth.rememberDevice": "Bu cihazı hatırla", + "auth.rememberDeviceHint": "Yönetici belirteci yerine iptal edilebilir HttpOnly oturumunu 30 gün tutar. Doğrudan HTTPS paneli (veya yerel loopback) gerekir.", + "connection.forgetDevice": "Bu cihazı unut", + "connection.forgetDeviceFailed": "Bu cihaz unutulamadı. Oturum korundu.", "auth.adminTokenDocsLink": "Nasıl bulunur", "app.logoAria": "opencodex logosu", "app.claudeOn": "Claude AÇIK", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a0b33ac014..9a5a1e6d84 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -184,6 +184,10 @@ export const vi: Record = { "auth.adminTokenRejected": "Token quản trị đó đã bị từ chối. Hãy kiểm tra lại và thử lại.", "auth.adminTokenUnavailable": "Không thể xác minh token quản trị. Hãy thử lại.", "auth.adminTokenHelp": "Đây là token quản trị quản lý OpenCodex, không phải là API key của provider. Proxy ghi nó vào ~/.opencodex/admin-api-token (hoặc $OPENCODEX_HOME/admin-api-token) trong lần khởi động đầu tiên và OPENCODEX_ADMIN_AUTH_TOKEN sẽ ghi đè nó.", + "auth.rememberDevice": "Ghi nhớ thiết bị này", + "auth.rememberDeviceHint": "Giữ phiên HttpOnly có thể thu hồi trong 30 ngày, không lưu mã quản trị. Yêu cầu bảng điều khiển HTTPS trực tiếp (hoặc loopback cục bộ).", + "connection.forgetDevice": "Quên thiết bị này", + "connection.forgetDeviceFailed": "Không thể quên thiết bị này. Phiên vẫn được giữ.", "auth.adminTokenDocsLink": "Cách tìm nó", "app.logoAria": "logo opencodex", "app.claudeOn": "Claude BẬT", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2378eaedf..ba4fb2e614 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2791,6 +2791,10 @@ export const zhTW: Record = { "auth.adminTokenRejected": "該管理員金鑰被拒絕。請檢查後再試一次。", "auth.adminTokenUnavailable": "無法驗證管理員金鑰。請再試一次。", "auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。", + "auth.rememberDevice": "記住此裝置", + "auth.rememberDeviceHint": "保留可撤銷的 HttpOnly 工作階段30天,不儲存管理員權杖。需要直接存取 HTTPS 儀表板(或本機回環)。", + "connection.forgetDevice": "忘記此裝置", + "connection.forgetDeviceFailed": "無法忘記此裝置。工作階段已保留。", "auth.adminTokenDocsLink": "如何尋找", "lang.nativeName": "繁體中文", "provider.name.commandCodeAuth": "Command Code - Auth", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf32dcc5a8..9e47d24acb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -252,6 +252,10 @@ export const zh: Record = { "auth.adminTokenRejected": "管理员令牌被拒绝。请检查后重试。", "auth.adminTokenUnavailable": "无法验证管理员令牌。请重试。", "auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。", + "auth.rememberDevice": "记住此设备", + "auth.rememberDeviceHint": "保留可撤销的 HttpOnly 会话30天,不保存管理员令牌。需要直接访问 HTTPS 仪表板(或本地回环)。", + "connection.forgetDevice": "忘记此设备", + "connection.forgetDeviceFailed": "无法忘记此设备。会话已保留。", "auth.adminTokenDocsLink": "如何查找", "theme.label": "主题", "theme.light": "浅色", diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts new file mode 100644 index 0000000000..e7a226be35 --- /dev/null +++ b/gui/tests/persistent-dashboard.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { promptForAdminToken } from "../src/admin-token-dialog"; +import { hasApiSession, installApiAuthFetch, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests } from "../src/api"; + +const names = ["window", "document", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const; +let previous: unknown[]; +let browser: Window; +beforeEach(() => { + previous = names.map(name => Reflect.get(globalThis, name)); + browser = new Window({ url: "https://dashboard.example.test/" }); + for (const name of names) Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(browser, name) }); + const meta = document.createElement("meta"); meta.name = "opencodex-management-auth-required"; meta.content = "1"; document.head.append(meta); + resetApiAuthFetchForTests(); +}); +afterEach(() => { resetApiAuthFetchForTests(); browser.close(); names.forEach((name, i) => Object.defineProperty(globalThis, name, { configurable: true, value: previous[i] })); }); + +test("remember is opt-in and asks the server to persist, never browser storage", async () => { + let requested: unknown; + const pending = promptForAdminToken(async (_token, remember) => { requested = remember; return "accepted"; }); + const box = document.querySelector('input[name="remember-device"]'); + expect(box).not.toBeNull(); expect(box!.checked).toBe(false); + box!.checked = true; + const form = document.querySelector("form")!; + form.querySelector('input[name="password"]')!.value = "ocx_admin_fixture"; + form.dispatchEvent(new browser.Event("submit", { cancelable: true })); + expect(await pending).toBe("ocx_admin_fixture"); expect(requested).toBe(true); + expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); expect(document.cookie).toBe(""); +}); + +test("remember installs the returned GUI session instead of retrying with the admin token", async () => { + const seen: Array<{ path: string; headers: Headers; init?: RequestInit }> = []; + resetApiAuthFetchForTests(async (verify, allowed) => { expect(allowed).toBe(true); expect(await verify("ocx_admin_fixture", true)).toBe("accepted"); return "ocx_admin_fixture"; }); + window.fetch = (async (input, init) => { + const path = String(input); const headers = new Headers(init?.headers); seen.push({ path, headers, init }); + if (path.endsWith("/api/session/remember")) return Response.json({ session: { token: "ocx_session_remembered", csrfToken: "csrf-fixture", browserOrigin: window.location.origin, serverOrigin: window.location.origin } }); + if (path === "/api/config" && headers.get("X-OpenCodex-API-Key") === "ocx_session_remembered") return Response.json({ ok: true }); + return new Response(null, { status: 401 }); + }) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(200); expect(hasApiSession("shared")).toBe(true); + const exchange = seen.find(row => row.path.endsWith("/api/session/remember"))!; + expect(exchange.init?.method).toBe("POST"); expect(exchange.init?.credentials).toBe("same-origin"); + expect(seen.filter(row => row.path === "/api/config").some(row => row.headers.get("X-OpenCodex-API-Key") === "ocx_admin_fixture")).toBe(false); +}); + +test("obsolete remembered bearer tokens are removed without reading or migrating them", () => { + localStorage.setItem("opencodex.remembered-admin-token", "unsafe-legacy"); + localStorage.setItem("opencodex.remembered-admin-token:https://server|same-origin", "unsafe-scoped"); + localStorage.setItem("opencodex.remembered-admin-token-decoy", "unrelated"); + const getter = localStorage.getItem.bind(localStorage); let reads = 0; + localStorage.getItem = key => { reads++; return getter(key); }; + installApiAuthFetch(); + expect(reads).toBe(0); expect(getter("opencodex.remembered-admin-token")).toBeNull(); + expect(getter("opencodex.remembered-admin-token:https://server|same-origin")).toBeNull(); + expect(getter("opencodex.remembered-admin-token-decoy")).toBe("unrelated"); +}); + +test("a hung Remember response body is aborted and cannot install a session", async () => { + let verdict: unknown; let attempts = 0; let signal: AbortSignal | undefined; + resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); + setRebootstrapTimeoutForTests(20); + window.fetch = (async (input, init) => { + if (!String(input).endsWith("/api/session/remember")) return new Response(null, { status: 401 }); + attempts++; signal = init?.signal as AbortSignal; + const response = new Response(null); + response.json = () => new Promise(() => {}); + return response; + }) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(401); + expect(attempts).toBe(1); expect(verdict).toBe("unavailable"); expect(signal?.aborted).toBe(true); expect(hasApiSession("shared")).toBe(false); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf..4d2522a87b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1598,6 +1598,7 @@ "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", "server-management-log-admission.test.ts": "server", + "persistent-dashboard-session.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 71f7d43d43..a986db0e8a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -50,6 +50,7 @@ let _corsOrigin = "http://localhost:10100"; // Listener identity is process-local. Never infer this trust from forwarded headers: the // request handler marks a Request only after resolving the actual Bun Server instance. const trustedHubManagementRequests = new WeakSet(); +export function isTrustedHubManagementRequest(req: Request): boolean { return trustedHubManagementRequests.has(req); } export function markTrustedHubManagementRequest(req: Request): void { trustedHubManagementRequests.add(req); diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index daaf5a865f..581a402c50 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -11,10 +11,13 @@ import { export type GuiSessionIssuance = | "loopback" + | "remembered-admin" | "tailscale-identity" | "pairing"; export interface GuiSessionRecord { + persistentDigest?: string; + persistentExpiresAt?: number; serverOrigin: string; browserOrigin: string; csrfToken: string; @@ -128,7 +131,7 @@ function evictOldestSession(state: GuiSessionState): void { } } -function mintSession( +export function mintSession( serverOrigin: string, browserOrigin: string, issuance: GuiSessionIssuance, @@ -457,6 +460,6 @@ export function authorizeGuiSessionRequest( const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" }; } - if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; + if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = Math.min(now + REMOTE_GUI_SESSION_TTL_MS, session.persistentExpiresAt ?? Infinity); return { ok: true, principal: "gui-session", session }; } diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 19104ef1bf..0c9ed366e3 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -72,6 +72,7 @@ import { type GuiSessionRequestContext, } from "./gui-session"; import { hasLocalDesktopSnapshotCapability } from "./local-desktop-snapshot-auth"; +import { forgetGuiDevice, rememberGuiDevice, rememberedGuiSessionIsCurrent, resumeGuiDevice } from "./persistent-gui-session"; import { hasLocalAccountSwitchCapability } from "./local-account-switch-auth"; export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session"; @@ -259,7 +260,7 @@ export function issueGuiSession( context?: GuiSessionRequestContext, ): GuiSessionBootstrap | null { if (!state.available) return null; - return issueGuiSessionFromState(req, config, state, context); + return resumeGuiDevice(req, config, state, context?.now) ?? issueGuiSessionFromState(req, config, state, context); } /** Returns the recorded issuance for the session that authorized this request. */ @@ -274,6 +275,8 @@ export function managementSessionIssuance( } export interface ManagementSessionControl { + rememberDevice?(req: Request, config: OcxConfig): ReturnType; + forgetDevice?(req: Request, config: OcxConfig): string | null; revokeCurrent(req: Request): boolean; /** Revalidate a long-lived request against current authority, without cached admission or renewal. */ isCurrent(req: Request, config: OcxConfig): boolean; @@ -290,7 +293,7 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana const credential = requestManagementCredential(req); if (!credential || equalSecret(credential, adminToken)) return null; const session = state.sessions.get(credential); - if (!session) return null; + if (!session || !rememberedGuiSessionIsCurrent(req, config, state, session)) return null; // Reuse the full origin/expiry/CSRF predicate against the current record, but // isolate its sliding-expiry mutation: authority checks are not browser activity. return authorizeGuiSessionRequest(req, config, { @@ -299,6 +302,8 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana }).ok ? session : null; } return { + rememberDevice(req, config) { return state.available ? rememberGuiDevice(req, config, state) : null; }, + forgetDevice(req, config) { return state.available ? forgetGuiDevice(req, config, state) : null; }, isCurrent(req: Request, config: OcxConfig): boolean { if (!state.available) return false; const credential = requestManagementCredential(req); @@ -581,7 +586,10 @@ function resolveManagementAdmission( else if (actual && isOpencodeCatalogRequest(req) && equalSecret(actual, opencodeCatalogToken(state.token))) { principal = "opencode-catalog-token"; } - else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session"; + else if (config) { + const admission = authorizeGuiSessionRequest(req, config, state); + if (admission.ok && rememberedGuiSessionIsCurrent(req, config, state, admission.session)) principal = "gui-session"; + } } if (principal) admittedManagementRequests.set(req, principal); return principal; diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 3a24f7138f..6f62598cc1 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -384,6 +384,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/routing-profiles/dry-run", module: "server/management/routing-profile-routes", mutates: true }, { method: "PUT", path: "/api/routing-profiles", module: "server/management/routing-profile-routes", mutates: true }, // server/management/session-routes + { method: "POST", path: "/api/session/remember", module: "server/management/session-routes", mutates: true, exempt: { reason: "session-only", why: "Explicit admin-token exchange for a revocable HttpOnly device cookie, restricted to a same-origin HTTPS dashboard or loopback. The CLI does not own a browser cookie jar." } }, { method: "POST", path: "/api/session/logout", module: "server/management/session-routes", mutates: true, exempt: { reason: "session-only", why: "Logs out the CURRENT gui-session and requires its own Origin and CSRF. There is nothing for a CLI verb to log out of: the CLI holds an admin token, and the admin token is refused here precisely so it cannot end a consent session it never established." } }, // server/management/sidebar-routes { method: "GET", path: "/api/github/star", module: "server/management/sidebar-routes", mutates: false }, diff --git a/src/server/management/session-routes.ts b/src/server/management/session-routes.ts index dd3fcd7ff5..22e493fe01 100644 --- a/src/server/management/session-routes.ts +++ b/src/server/management/session-routes.ts @@ -2,12 +2,30 @@ import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; export function handleSessionRoutes(ctx: ManagementContext): Response | null { + if (ctx.url.pathname === "/api/session/remember" && ctx.req.method === "POST") { + if (ctx.principal !== "admin-token") return jsonResponse({ error: "Admin token required" }, 403, ctx.req, ctx.config); + try { + const remembered = ctx.sessionControl?.rememberDevice?.(ctx.req, ctx.config); + if (!remembered) return jsonResponse({ error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin" }, 403, ctx.req, ctx.config); + const response = jsonResponse({ session: remembered.session }, 200, ctx.req, ctx.config); + response.headers.set("Set-Cookie", remembered.cookie); + response.headers.set("Cache-Control", "no-store"); + return response; + } catch { return jsonResponse({ error: "Could not persist dashboard session" }, 503, ctx.req, ctx.config); } + } if (ctx.url.pathname !== "/api/session/logout" || ctx.req.method !== "POST") return null; if (ctx.principal !== "gui-session") { return jsonResponse({ error: "GUI session required" }, 403, ctx.req, ctx.config); } - if (!ctx.sessionControl?.revokeCurrent(ctx.req)) { + let expiredCookie: string | null | undefined; + try { expiredCookie = ctx.sessionControl?.forgetDevice?.(ctx.req, ctx.config); } + catch { return jsonResponse({ error: "Could not forget dashboard session" }, 503, ctx.req, ctx.config); } + // Forget may already have revoked every short-lived session belonging to this device. + if (!ctx.sessionControl?.revokeCurrent(ctx.req) && !expiredCookie) { return jsonResponse({ error: "GUI session not found" }, 401, ctx.req, ctx.config); } - return jsonResponse({ ok: true }, 200, ctx.req, ctx.config); + const response = jsonResponse({ ok: true }, 200, ctx.req, ctx.config); + if (expiredCookie) response.headers.set("Set-Cookie", expiredCookie); + response.headers.set("Cache-Control", "no-store"); + return response; } diff --git a/src/server/persistent-gui-session.ts b/src/server/persistent-gui-session.ts new file mode 100644 index 0000000000..4c5c42215a --- /dev/null +++ b/src/server/persistent-gui-session.ts @@ -0,0 +1,143 @@ +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { withConfigMutationLockSync } from "../config/mutation-lock"; +import { atomicWriteFileNoFollow } from "../config/atomic-write"; +import type { OcxConfig } from "../types"; +import { isLoopbackHostname, isTrustedHubManagementRequest, managementRequestOrigin } from "./auth-cors"; +import { mintSession, type GuiSessionRecord, type GuiSessionState } from "./gui-session"; + +export const REMEMBERED_GUI_TTL_MS = 30 * 86400_000; +const RECORD_LIMIT = 128; +const STORE_LIMIT = 128 * 1024; +type State = GuiSessionState & { token: string }; +interface DeviceRecord { + digest: string; + credential: string; + serverOrigin: string; + userAgent: string; + expiresAt: number; +} +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const storePath = () => join(getConfigDir(), "dashboard-sessions.json"); + +function directOrigin(req: Request, config: OcxConfig): string | null { + const origin = managementRequestOrigin(req, config); + if (!origin) return null; + const url = new URL(origin); + if (new URL(req.url).origin !== origin && !isTrustedHubManagementRequest(req)) return null; + if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) return null; + const browser = req.headers.get("Origin"); + if (browser !== null && browser !== origin) return null; + const site = req.headers.get("Sec-Fetch-Site"); + return site && site !== "same-origin" && site !== "none" ? null : origin; +} + +function cookieName(origin: string): string { + return (origin.startsWith("https:") ? "__Host-" : "") + "ocx_dashboard_" + hash(origin).slice(0, 16); +} + +function deviceDigest(req: Request, origin: string): string | null { + const name = cookieName(origin); + const matches = (req.headers.get("Cookie") ?? "").split(";").map(part => part.trim()).filter(part => part.startsWith(name + "=")); + if (matches.length !== 1) return null; + const token = matches[0]!.slice(name.length + 1); + return /^ocx_device_[A-Za-z0-9_-]{43}$/.test(token) ? hash(token) : null; +} + +function cookie(origin: string, token: string, maxAge: number): string { + return cookieName(origin) + "=" + token + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=" + maxAge + + (origin.startsWith("https:") ? "; Secure" : ""); +} + +function readRecords(): DeviceRecord[] { + const path = storePath(); + let fd: number; + try { fd = openSync(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } + try { + const stat = fstatSync(fd); + const entry = lstatSync(path); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > STORE_LIMIT || entry.isSymbolicLink() + || stat.dev !== entry.dev || stat.ino !== entry.ino) throw new Error("unsafe dashboard session store"); + const bytes = Buffer.alloc(STORE_LIMIT + 1); + let length = 0; + while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } + if (length > STORE_LIMIT) throw new Error("dashboard session store too large"); + const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); + if (!Array.isArray(value) || value.length > RECORD_LIMIT) throw new Error("invalid dashboard session store"); + for (const row of value) { + if (!row || typeof row !== "object" || Object.keys(row).length !== 5 + || ![row.digest, row.credential, row.userAgent].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + || typeof row.serverOrigin !== "string" || row.serverOrigin.length > 2048 + || !Number.isSafeInteger(row.expiresAt) || row.expiresAt <= 0) throw new Error("invalid dashboard session record"); + const url = new URL(row.serverOrigin); + if (url.origin !== row.serverOrigin || !["http:", "https:"].includes(url.protocol)) throw new Error("invalid dashboard session origin"); + } + return value as DeviceRecord[]; + } finally { closeSync(fd); } +} + +function matches(row: DeviceRecord, req: Request, origin: string, state: State, now: number): boolean { + return row.serverOrigin === origin && row.credential === hash(state.token) + && row.userAgent === hash(req.headers.get("User-Agent") ?? "") && row.expiresAt > now; +} + +function mintDeviceSession(row: DeviceRecord, state: State, now: number) { + const session = mintSession(row.serverOrigin, row.serverOrigin, "remembered-admin", state, now); + const record = state.sessions.get(session.token)!; + record.persistentDigest = row.digest; + record.persistentExpiresAt = row.expiresAt; + session.expiresAt = Math.min(session.expiresAt, row.expiresAt); + return session; +} + +export function rememberGuiDevice(req: Request, config: OcxConfig, state: State, now = Date.now()) { + const origin = directOrigin(req, config); + if (req.method !== "POST" || !origin || req.headers.get("Origin") !== origin || req.body !== null) return null; + return withConfigMutationLockSync(() => { + const previous = deviceDigest(req, origin); + const rows = readRecords().filter(row => row.expiresAt > now && row.credential === hash(state.token)); + // Re-sign-in replaces only this device, never another server or browser's record. + const retained = rows.filter(row => !(row.digest === previous && matches(row, req, origin, state, now))); + if (retained.length >= RECORD_LIMIT) throw new Error("dashboard device limit reached"); + const token = "ocx_device_" + randomBytes(32).toString("base64url"); + const row: DeviceRecord = { digest: hash(token), credential: hash(state.token), serverOrigin: origin, + userAgent: hash(req.headers.get("User-Agent") ?? ""), expiresAt: now + REMEMBERED_GUI_TTL_MS }; + atomicWriteFileNoFollow(storePath(), JSON.stringify([...retained, row])); + return { session: mintDeviceSession(row, state, now), cookie: cookie(origin, token, REMEMBERED_GUI_TTL_MS / 1000) }; + }); +} + +export function resumeGuiDevice(req: Request, config: OcxConfig, state: State, now = Date.now()) { + const origin = directOrigin(req, config); + if (req.method !== "GET" || !origin) return null; + const digest = deviceDigest(req, origin); + if (!digest) return null; + try { + const row = readRecords().find(row => row.digest === digest && matches(row, req, origin, state, now)); + return row ? mintDeviceSession(row, state, now) : null; + } catch { return null; } +} + +export function rememberedGuiSessionIsCurrent(req: Request, config: OcxConfig, state: State, session: GuiSessionRecord): boolean { + if (!session.persistentDigest) return true; + const origin = directOrigin(req, config); + if (!origin || deviceDigest(req, origin) !== session.persistentDigest) return false; + try { return readRecords().some(row => row.digest === session.persistentDigest && matches(row, req, origin, state, Date.now())); } + catch { return false; } +} + +export function forgetGuiDevice(req: Request, config: OcxConfig, state: State): string | null { + const origin = directOrigin(req, config); + if (!origin || req.method !== "POST") return null; + const digest = deviceDigest(req, origin); + if (digest) withConfigMutationLockSync(() => { + const rows = readRecords(); + const found = rows.find(row => row.digest === digest && matches(row, req, origin, state, Date.now())); + if (found) atomicWriteFileNoFollow(storePath(), JSON.stringify(rows.filter(row => row !== found))); + for (const [token, session] of state.sessions) if (session.persistentDigest === digest) state.sessions.delete(token); + }); + return cookie(origin, "", 0); +} diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 3cbebeb3a1..15963c6d0f 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -7,7 +7,7 @@ in [GUI and management API](gui-and-management-api.md). ## UX boundary The dashboard is a local control surface, not a separate service. It should reflect the same config -and catalog invariants documented in this folder rather than inventing parallel state. +and catalog invariants documented in this folder rather than inventing parallel state. The sign-in form offers opt-in **Remember this device** for direct HTTPS dashboards or loopback, requesting a server-issued HttpOnly session rather than storing an admin token. Both mobile and sidebar actions offer **Forget this device**, using logout to revoke durable and transient device authority; session-change events update both surfaces. Expiry and cookie authentication belong to [GUI and management API](gui-and-management-api.md). Codex quota cards consume the display cache from `src/codex/quota.ts`. Partial refreshes remove omitted short tuples after their reset deadline; validated main-window absence also removes diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07e..1c18024ca7 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -119,7 +119,7 @@ OpenCodex uses three mutually exclusive reusable admission credential classes: The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential -does not enable management access. The data plane may continue to start, but `/api/*` remains closed. +does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` exchanges admin-token authority for a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS), never browser-stored admin credentials. `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB) and fixed 30-day expiry. Enrollment requires direct same-origin HTTPS or loopback; relay, cross-origin and remote HTTP are refused. Cookie hashes bind server origin (including port), browser identity and admin-credential fingerprint. Bootstrap issues short-lived origin/CSRF-bound `remembered-admin` sessions, which never count as pairing or data-key-reveal consent. Every derived session still requires the cookie and durable record; logout durably revokes both before reporting success. Legacy bearer-token storage is purged without reading or migrating it. Tests: `tests/server/persistent-dashboard-session.test.ts`. CLI health collection follows the same boundary without transporting the reusable management credential. Its local-read HMAC capability is an additional single-use, route-scoped admission mechanism, not a reusable credential class. `ocx doctor` and OAuth health derive these capabilities diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d5..18b1829dd7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1048,6 +1048,7 @@ "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", + "persistent-dashboard-session.test.ts": "server", "server-management-log-admission.test.ts": "server", "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", diff --git a/tests/server/persistent-dashboard-session.test.ts b/tests/server/persistent-dashboard-session.test.ts new file mode 100644 index 0000000000..3ffa44b5f6 --- /dev/null +++ b/tests/server/persistent-dashboard-session.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { initializeManagementAuthState, createManagementSessionControl, issueGuiSession, requireManagementAuth } from "../../src/server/management-auth"; +import { handleSessionRoutes } from "../../src/server/management/session-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import { remoteConfig } from "../helpers/management-auth-fixture"; + +const previousHome = process.env.OPENCODEX_HOME; +const previousToken = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; +let home: string; +const config = remoteConfig(); +const origin = "https://dashboard.example.test"; +function request(path = "/api/session/remember", cookie?: string, overrides: HeadersInit = {}, method = "POST") { + return new Request(origin + path, { method, headers: { Host: "dashboard.example.test", Origin: origin, + "User-Agent": "Safari-PWA-fixture", "X-OpenCodex-API-Key": "ocx_admin_fixture", ...(cookie ? { Cookie: cookie } : {}), ...overrides } }); +} +function login() { + const state = initializeManagementAuthState(config); + const req = request(); + const response = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "admin-token", + sessionControl: createManagementSessionControl(state) } as ManagementContext); + return { state, response }; +} +beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-persistent-dashboard-")); process.env.OPENCODEX_HOME = home; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_fixture"; }); +afterEach(() => { process.env.OPENCODEX_HOME = previousHome; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = previousToken; rmSync(home, { recursive: true, force: true }); }); + +test("remember exchanges the admin token for a device cookie and a server-issued session", async () => { + const { response } = login(); + expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!; + expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("Secure"); expect(cookie).toContain("SameSite=Strict"); expect(cookie).toContain("Path=/"); expect(cookie).not.toContain("Domain="); expect(cookie).not.toContain("ocx_admin_fixture"); + const body = await response!.json(); + expect(body.session.token).toStartWith("ocx_session_"); + expect(body.session.issuance).toBe("remembered-admin"); + const disk = readFileSync(join(home, "dashboard-sessions.json"), "utf8"); + expect(disk).not.toContain("ocx_admin_fixture"); + expect(disk).not.toContain(cookie.split(";")[0]!.split("=")[1]!); +}); + +test("a device resumes after a server restart, but not on another origin or browser", () => { + const { response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const restarted = initializeManagementAuthState(config); + const resumed = issueGuiSession(request("/opencodex-session", cookie, {}, "GET"), config, restarted); + expect(resumed?.token).toStartWith("ocx_session_"); + expect(issueGuiSession(request("/", cookie, { "User-Agent": "another-device" }, "GET"), config, restarted)).toBeNull(); + const other = new Request("https://other.example.test/", { headers: { Host: "other.example.test", Cookie: cookie, "User-Agent": "Safari-PWA-fixture" } }); + expect(issueGuiSession(other, config, restarted)).toBeNull(); +}); + +test("expiry is absolute and rotating the admin token invalidates persistent devices", () => { + const { response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const req = request("/", cookie, {}, "GET"); + expect(issueGuiSession(req, config, initializeManagementAuthState(config), { trustedTailscaleIngress: false, now: Date.now() + 31 * 86400_000 })).toBeNull(); + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx_admin_rotated"; + expect(issueGuiSession(req, config, initializeManagementAuthState(config))).toBeNull(); +}); + +test("logout durably forgets this device and revokes all resumed sessions", async () => { + const { state, response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const resumed = issueGuiSession(request("/", cookie, {}, "GET"), config, state)!; + const session = (await response!.json()).session; + const req = request("/api/session/logout", cookie, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin, "X-OpenCodex-CSRF-Token": session.csrfToken }); + const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: createManagementSessionControl(state) } as ManagementContext); + expect(result?.status).toBe(200); expect(result!.headers.get("set-cookie")).toContain("Max-Age=0"); + expect(issueGuiSession(request("/", cookie, {}, "GET"), config, initializeManagementAuthState(config))).toBeNull(); + const replay = request("/api/config", cookie, { "X-OpenCodex-API-Key": resumed.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + expect(requireManagementAuth(replay, state, config)?.status).toBe(401); +}); + +test("remember rejects cross-origin, plaintext remote and unauthenticated issuance", () => { + const state = initializeManagementAuthState(config); const control = createManagementSessionControl(state); + for (const req of [request(undefined, undefined, { Origin: "https://attacker.test" }), new Request("http://dashboard.example.test/api/session/remember", { method: "POST", headers: { Host: "dashboard.example.test", Origin: "http://dashboard.example.test" } })]) { + const result = handleSessionRoutes({ req, url: new URL(req.url), config, principal: "admin-token", sessionControl: control } as ManagementContext); + expect(result?.status).toBe(403); + } + const req = request(); expect(handleSessionRoutes({ req, url: new URL(req.url), config, principal: "gui-session", sessionControl: control } as ManagementContext)?.status).toBe(403); +}); + +test("the persistent cookie alone grants no management access, and a derived token needs its cookie", async () => { + const { state, response } = login(); expect(response?.status).toBe(200); + const cookie = response!.headers.get("set-cookie")!.split(";")[0]!; + const session = (await response!.json()).session; + const cookieOnly = request("/api/config", cookie, {}, "GET"); cookieOnly.headers.delete("X-OpenCodex-API-Key"); + expect(requireManagementAuth(cookieOnly, state, config)?.status).toBe(401); + const tokenOnly = request("/api/config", undefined, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + expect(requireManagementAuth(tokenOnly, state, config)?.status).toBe(401); + const both = request("/api/config", cookie, { "X-OpenCodex-API-Key": session.token, "X-OpenCodex-GUI-Origin": origin }, "GET"); + const control = createManagementSessionControl(state); + expect(requireManagementAuth(both, state, config)).toBeNull(); + expect(control.isPaired(both, config)).toBe(false); expect(control.canRevealDataKeys?.(both, config)).toBe(false); +}); + +test("an unreadable or malformed device store fails closed without issuing a cookie or overwriting it", () => { + writeFileSync(join(home, "dashboard-sessions.json"), "not-json", { mode: 0o600 }); + const { response } = login(); expect(response?.status).toBe(503); + expect(response!.headers.get("set-cookie")).toBeNull(); + expect(readFileSync(join(home, "dashboard-sessions.json"), "utf8")).toBe("not-json"); +});