407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
/**
|
|
* Auth token management - bootstrap, persist, invalidate.
|
|
*
|
|
* Extracted from client.js to keep the API surface thin.
|
|
*
|
|
* Security boundary - sessionStorage vs localStorage
|
|
* ===================================================
|
|
* Write-auth tokens are stored in **sessionStorage** on purpose:
|
|
* - Session-scoped: cleared when the tab closes (no long-lived secret on disk).
|
|
* - Same-origin only: inaccessible to other tabs/windows that haven't
|
|
* bootstrapped their own token.
|
|
* - Not sent automatically in HTTP requests: fetched explicitly per-call and
|
|
* injected 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 migrated
|
|
* to sessionStorage once and then wiped from localStorage immediately.
|
|
*
|
|
* Web Crypto encryption of the sessionStorage value was evaluated and deemed
|
|
* unnecessary: sessionStorage is already same-origin-scoped and tab-scoped,
|
|
* and the token lifetime is short (session-only). Adding encryption would
|
|
* increase complexity without meaningful security gain in this context.
|
|
*/
|
|
|
|
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 RUNTIME_TOKEN_KEY = "__mjr_write_token";
|
|
const AUTH_TOKEN_CACHE_KEY = "token";
|
|
|
|
let _authTokenRefreshInFlight: any = null;
|
|
let _lastAuthBootstrapFailure: any = null;
|
|
let _lastWriteAuthToast: any = null;
|
|
|
|
const _authTokenCache = createTTLCache({ ttlMs: AUTH_TOKEN_CACHE_TTL_MS, maxSize: 1 });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Low-level session / localStorage helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _readSessionAuthToken() {
|
|
try {
|
|
return String(sessionStorage?.getItem?.(RUNTIME_TOKEN_KEY) || "").trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _writeSessionAuthToken(token: any) {
|
|
const normalized = String(token || "").trim();
|
|
try {
|
|
if (normalized) {
|
|
sessionStorage?.setItem?.(RUNTIME_TOKEN_KEY, normalized);
|
|
} else {
|
|
sessionStorage?.removeItem?.(RUNTIME_TOKEN_KEY);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
_writeSessionAuthToken("");
|
|
_clearLocalSettingsAuthToken();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read / persist
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function readAuthToken() {
|
|
const cached = _authTokenCache.get(AUTH_TOKEN_CACHE_KEY);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const now = Date.now();
|
|
|
|
const sessionToken = _readSessionAuthToken();
|
|
if (sessionToken) {
|
|
_authTokenCache.set(AUTH_TOKEN_CACHE_KEY, sessionToken, { at: now });
|
|
return sessionToken;
|
|
}
|
|
|
|
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) {
|
|
_writeSessionAuthToken(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;
|
|
_writeSessionAuthToken(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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
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();
|
|
}
|