Files

402 lines
14 KiB
TypeScript

/**
* Auth token management - bootstrap, retain, invalidate.
*
* Extracted from client.js to keep the API surface thin.
*
* Security boundary - memory vs localStorage
* ==========================================
* Write-auth tokens are retained in module memory only:
* - Cleared on reload/navigation instead of being written to browser storage.
* - Not sent automatically in HTTP requests: injected explicitly per write
* call via the `X-MJR-Token` header.
*
* **localStorage** is used only for user-facing settings (never for secrets).
* If a legacy `security.apiToken` value is found in localStorage it is loaded
* into memory once and then wiped from localStorage immediately.
*/
import { SETTINGS_KEY } from "../app/settingsStore.js";
import { t } from "../app/i18n.js";
import { comfyToast } from "../app/toast.js";
import { createTTLCache } from "../utils/ttlCache.js";
const AUTH_TOKEN_CACHE_TTL_MS = 2000;
const AUTH_BOOTSTRAP_FAILURE_TTL_MS = 15_000;
const WRITE_AUTH_TOAST_TTL_MS = 8_000;
const AUTH_TOKEN_CACHE_KEY = "token";
let _authTokenRefreshInFlight: any = null;
let _lastAuthBootstrapFailure: any = null;
let _lastWriteAuthToast: any = null;
let _runtimeAuthToken = "";
const _authTokenCache = createTTLCache({ ttlMs: AUTH_TOKEN_CACHE_TTL_MS, maxSize: 1 });
// ---------------------------------------------------------------------------
// Low-level runtime / localStorage helpers
// ---------------------------------------------------------------------------
function _readRuntimeAuthToken() {
return String(_runtimeAuthToken || "").trim();
}
function _writeRuntimeAuthToken(token: any) {
_runtimeAuthToken = String(token || "").trim();
return true;
}
function _clearLocalSettingsAuthToken() {
try {
const raw = localStorage?.getItem?.(SETTINGS_KEY);
const parsed = raw ? JSON.parse(raw) : {};
const next = parsed && typeof parsed === "object" ? parsed : {};
const target = next?.data && typeof next.data === "object" ? next.data : next;
if (
target?.security &&
typeof target.security === "object" &&
String(target.security.apiToken || "").trim()
) {
target.security.apiToken = "";
localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next));
}
} catch (e) {
console.debug?.(e);
}
}
function _clearAuthToken() {
try {
_authTokenCache.delete(AUTH_TOKEN_CACHE_KEY);
} catch (e) {
console.debug?.(e);
}
_writeRuntimeAuthToken("");
_clearLocalSettingsAuthToken();
}
// ---------------------------------------------------------------------------
// Read / persist
// ---------------------------------------------------------------------------
export function readAuthToken() {
const cached = _authTokenCache.get(AUTH_TOKEN_CACHE_KEY);
if (cached !== undefined) {
return cached;
}
const now = Date.now();
const runtimeToken = _readRuntimeAuthToken();
if (runtimeToken) {
_authTokenCache.set(AUTH_TOKEN_CACHE_KEY, runtimeToken, { at: now });
return runtimeToken;
}
try {
const raw = localStorage?.getItem?.(SETTINGS_KEY);
const parsed = raw ? JSON.parse(raw) : null;
const payload = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed;
const token = String(payload?.security?.apiToken || "").trim();
if (token) {
_writeRuntimeAuthToken(token);
try {
const mutable = parsed && typeof parsed === "object" ? parsed : {};
const target =
mutable?.data && typeof mutable.data === "object" ? mutable.data : mutable;
if (target?.security && typeof target.security === "object") {
target.security.apiToken = "";
localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(mutable));
window?.dispatchEvent?.(
new CustomEvent("mjr-settings-changed", {
detail: { key: "security.apiToken" },
}),
);
}
} catch (e) {
console.debug?.(e);
}
}
_authTokenCache.set(AUTH_TOKEN_CACHE_KEY, token, { at: now });
return token;
} catch {
_authTokenCache.set(AUTH_TOKEN_CACHE_KEY, "", { at: now });
return "";
}
}
export function _persistAuthToken(token: any) {
const normalized = String(token || "").trim();
if (!normalized) return false;
try {
_authTokenCache.set(AUTH_TOKEN_CACHE_KEY, normalized);
_lastAuthBootstrapFailure = null;
_writeRuntimeAuthToken(normalized);
_clearLocalSettingsAuthToken();
try {
window?.dispatchEvent?.(
new CustomEvent("mjr-settings-changed", { detail: { key: "security.apiToken" } }),
);
} catch (e) {
console.debug?.(e);
}
return true;
} catch {
return false;
}
}
// RFC 6750 token68 charset + underscore. Covers "mjr_<hex>" generated tokens and
// standard Bearer token values. Rejects control chars, spaces, or injection attempts
// before the value reaches an Authorization header.
const _TOKEN_FORMAT_RE = /^[A-Za-z0-9._\-~+/]+=*$/;
export function setRuntimeSecurityToken(token: any) {
const normalized = String(token || "").trim();
if (!normalized) return false;
if (!_TOKEN_FORMAT_RE.test(normalized)) {
console.debug?.("[MJR auth] Rejected malformed security token (invalid characters)");
return false;
}
return _persistAuthToken(normalized);
}
export function hasRuntimeSecurityToken() {
return !!readAuthToken();
}
// ---------------------------------------------------------------------------
// Bootstrap failure tracking
// ---------------------------------------------------------------------------
function _rememberAuthBootstrapFailure(details: Record<string, any> = {}) {
const code = String(details?.code || "")
.trim()
.toUpperCase();
const error = String(details?.error || "").trim();
const status = Number(details?.status || 0) || 0;
_lastAuthBootstrapFailure = {
code,
error,
status,
at: Date.now(),
};
}
function _readAuthBootstrapFailure() {
const cached = _lastAuthBootstrapFailure;
if (!cached) return null;
const age = Date.now() - (Number(cached.at || 0) || 0);
if (age < 0 || age > AUTH_BOOTSTRAP_FAILURE_TTL_MS) {
_lastAuthBootstrapFailure = null;
return null;
}
return cached;
}
// ---------------------------------------------------------------------------
// Write-auth error messaging
// ---------------------------------------------------------------------------
function _buildWriteAuthErrorMessage(result: any) {
const failure = _readAuthBootstrapFailure();
const resultCode = String(result?.code || "")
.trim()
.toUpperCase();
const resultError = String(result?.error || "").trim();
const failureCode = String(failure?.code || "")
.trim()
.toUpperCase();
const failureError = String(failure?.error || "")
.trim()
.toLowerCase();
const resultErrorLower = resultError.toLowerCase();
if (
resultCode === "FORBIDDEN" &&
(resultErrorLower.includes("api token over insecure transport") ||
resultErrorLower.includes("allow http token transport"))
) {
return t(
"toast.writeAuthInsecureTransport",
"Write access is blocked because the Majoor API token is being sent over plain HTTP from a remote machine. Use HTTPS, or enable Settings -> Security -> Allow HTTP Token Transport for a trusted LAN.",
);
}
if (
failureCode === "FORBIDDEN" &&
(failureError.includes("already configured") || failureError.includes("rotate-token"))
) {
return t(
"toast.writeAuthConfiguredTokenRequired",
"Write access requires the Majoor API token already configured on the server. Open Settings -> Security -> API Token and enter the matching token.",
);
}
if (
failureCode === "AUTH_REQUIRED" &&
(failureError.includes("sign in to comfyui") ||
failureError.includes("authenticated comfyui user"))
) {
return t(
"toast.writeAuthSignInRequired",
"Write access is blocked. Sign in to ComfyUI first, then retry so Majoor can bootstrap the remote session token automatically.",
);
}
if (
failureCode === "BOOTSTRAP_DISABLED" ||
(failureCode === "AUTH_REQUIRED" && failureError.includes("bootstrap")) ||
(resultCode === "AUTH_REQUIRED" && resultErrorLower.includes("api token"))
) {
return t(
"toast.writeAuthBootstrapHelp",
"Write access is blocked. Sign in to ComfyUI and retry so Majoor can bootstrap the remote session automatically, or set a Majoor API token in Settings -> Security.",
);
}
return "";
}
function _notifyWriteAuthFailure(message: any) {
const normalized = String(message || "").trim();
if (!normalized) return;
const now = Date.now();
const cached = _lastWriteAuthToast;
if (
cached &&
cached.message === normalized &&
now - (Number(cached.at || 0) || 0) < WRITE_AUTH_TOAST_TTL_MS
) {
return;
}
_lastWriteAuthToast = { message: normalized, at: now };
try {
comfyToast(
{
summary: t("toast.writeAuthTitle", "Majoor remote write access"),
detail: normalized,
},
"warning",
6500,
{ noHistory: true },
);
} catch (e) {
console.debug?.(e);
}
}
export function normalizeWriteAuthFailure(result: any) {
const code = String(result?.code || "")
.trim()
.toUpperCase();
const error = String(result?.error || "")
.trim()
.toLowerCase();
const authLikeForbidden = code === "FORBIDDEN" && error.includes("write operation blocked");
if (code !== "AUTH_REQUIRED" && !authLikeForbidden) {
return result;
}
const message = _buildWriteAuthErrorMessage(result);
if (!message) {
return result;
}
_notifyWriteAuthFailure(message);
return { ...result, error: message };
}
// ---------------------------------------------------------------------------
// Server bootstrap
// ---------------------------------------------------------------------------
async function _refreshAuthTokenFromServer() {
try {
const response = await fetch("/mjr/am/settings/security/bootstrap-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: "{}",
});
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
_rememberAuthBootstrapFailure({
code: "INVALID_RESPONSE",
error: `Bootstrap token request returned non-JSON response (${response.status})`,
status: response.status,
});
return { ok: false, token: false };
}
const payload = await response.json().catch((e) => {
console.debug?.("[MJR auth] JSON parse error:", e);
return null;
});
if (!payload || typeof payload !== "object") {
_rememberAuthBootstrapFailure({
code: "INVALID_RESPONSE",
error: "Bootstrap token response was invalid.",
status: response.status,
});
return { ok: false, token: false };
}
if (!payload.ok) {
_rememberAuthBootstrapFailure({
code: payload?.code,
error: payload?.error,
status: response.status,
});
return { ok: false, token: false };
}
const token = String(payload?.data?.token || "").trim();
if (token) return { ok: _persistAuthToken(token), token: true };
_lastAuthBootstrapFailure = null;
return { ok: true, token: false };
} catch (error: any) {
_rememberAuthBootstrapFailure({
code: "NETWORK_ERROR",
error: (error as any)?.message || "Bootstrap token request failed.",
status: 0,
});
return { ok: false, token: false };
}
}
export async function ensureWriteAuthToken({ force = false, allowCookieRefresh = false } = {}) {
const existing = readAuthToken();
if (existing && !force) return existing;
let refreshResult = { ok: false, token: false };
if (!_authTokenRefreshInFlight) {
_authTokenRefreshInFlight = (async () => {
try {
return await _refreshAuthTokenFromServer();
} finally {
_authTokenRefreshInFlight = null;
}
})();
}
try {
refreshResult = (await _authTokenRefreshInFlight) || refreshResult;
} catch (e) {
console.debug?.(e);
}
if (force && refreshResult?.ok && !refreshResult?.token && existing) {
_clearAuthToken();
} else if (force && !refreshResult?.ok) {
const failure = _readAuthBootstrapFailure();
const failureCode = String(failure?.code || "")
.trim()
.toUpperCase();
if (!failureCode || !["NETWORK_ERROR", "INVALID_RESPONSE"].includes(failureCode)) {
_clearAuthToken();
}
}
const nextToken = readAuthToken();
if (!nextToken && allowCookieRefresh && refreshResult?.ok) {
return true;
}
return nextToken;
}
export function invalidateAuthTokenCache() {
_authTokenCache.clear();
}