402 lines
14 KiB
TypeScript
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();
|
|
}
|