Fixes switching and unlocking password-protected remote instances Stores SSH forwarded host client tokens from saved UI passwords Avoids unnecessary auth churn when no runtime headers are configured
381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
type RuntimeAuthCredential =
|
|
| { type: 'bearer'; token: string }
|
|
| null;
|
|
|
|
export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promise<RuntimeAuthCredential>;
|
|
|
|
let credentialProvider: RuntimeAuthCredentialProvider = () => null;
|
|
let runtimeBearerToken = '';
|
|
let runtimeExtraHeaders: Record<string, string> = {};
|
|
let runtimeUrlAuthToken = '';
|
|
let runtimeUrlAuthTokenExpiresAt = 0;
|
|
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
|
let localRuntimeUrlAuthToken = '';
|
|
let localRuntimeUrlAuthTokenExpiresAt = 0;
|
|
let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
|
let runtimeAuthGeneration = 0;
|
|
|
|
const URL_AUTH_REFRESH_SKEW_MS = 10_000;
|
|
|
|
const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization';
|
|
|
|
const sanitizeRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): Record<string, string> => {
|
|
const next: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(headers || {})) {
|
|
const name = key.trim();
|
|
const headerValue = value.trim();
|
|
if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue;
|
|
}
|
|
return next;
|
|
};
|
|
|
|
const runtimeExtraHeadersEqual = (left: Record<string, string>, right: Record<string, string>): boolean => {
|
|
const leftEntries = Object.entries(left);
|
|
if (leftEntries.length !== Object.keys(right).length) return false;
|
|
return leftEntries.every(([key, value]) => right[key] === value);
|
|
};
|
|
|
|
const normalizeBearerToken = (token: string | null | undefined): string => {
|
|
if (typeof token !== 'string') return '';
|
|
return token.trim();
|
|
};
|
|
|
|
const readInjectedBearerToken = (): string => {
|
|
if (typeof window === 'undefined') return '';
|
|
const injected = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__;
|
|
return normalizeBearerToken(injected);
|
|
};
|
|
|
|
const readInjectedApiBaseUrl = (): string => {
|
|
if (typeof window === 'undefined') return '';
|
|
const injected = (window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string }).__OPENCHAMBER_API_BASE_URL__;
|
|
return typeof injected === 'string' ? injected.trim() : '';
|
|
};
|
|
|
|
const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): string => {
|
|
const base = typeof apiBaseUrl === 'string' && apiBaseUrl.trim()
|
|
? apiBaseUrl.trim()
|
|
: readInjectedApiBaseUrl();
|
|
if (!base) return path;
|
|
try {
|
|
return new URL(path, `${base.replace(/\/+$/, '')}/`).toString();
|
|
} catch {
|
|
return path;
|
|
}
|
|
};
|
|
|
|
export const clearRuntimeUrlAuthToken = (): void => {
|
|
runtimeUrlAuthToken = '';
|
|
runtimeUrlAuthTokenExpiresAt = 0;
|
|
localRuntimeUrlAuthToken = '';
|
|
localRuntimeUrlAuthTokenExpiresAt = 0;
|
|
};
|
|
|
|
const resetRuntimeAuthGeneration = (): void => {
|
|
runtimeAuthGeneration += 1;
|
|
runtimeUrlAuthRefreshPromise = null;
|
|
clearRuntimeUrlAuthToken();
|
|
// Credentials changed: if a consumer is active, re-mint promptly.
|
|
scheduleUrlAuthRefresh();
|
|
};
|
|
|
|
export const setRuntimeAuthCredentialProvider = (provider: RuntimeAuthCredentialProvider): void => {
|
|
runtimeBearerToken = '';
|
|
resetRuntimeAuthGeneration();
|
|
credentialProvider = provider;
|
|
};
|
|
|
|
export const clearRuntimeAuthCredentialProvider = (): void => {
|
|
runtimeBearerToken = '';
|
|
resetRuntimeAuthGeneration();
|
|
credentialProvider = () => null;
|
|
};
|
|
|
|
export const setRuntimeBearerToken = (token: string | null | undefined): void => {
|
|
const normalized = normalizeBearerToken(token);
|
|
runtimeBearerToken = normalized;
|
|
resetRuntimeAuthGeneration();
|
|
credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null;
|
|
};
|
|
|
|
export const setRuntimeExtraHeaders = (headers: Record<string, string> | null | undefined): void => {
|
|
// These headers are for runtime HTTP fetches and URL-token minting. Browser-owned
|
|
// realtime transports (EventSource/WebSocket) cannot attach arbitrary headers.
|
|
const next = sanitizeRuntimeExtraHeaders(headers);
|
|
if (runtimeExtraHeadersEqual(runtimeExtraHeaders, next)) return;
|
|
runtimeExtraHeaders = next;
|
|
resetRuntimeAuthGeneration();
|
|
};
|
|
|
|
export const getRuntimeExtraHeadersSync = (): Record<string, string> => {
|
|
if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders;
|
|
if (typeof window === 'undefined') return {};
|
|
const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record<string, string> }).__OPENCHAMBER_RUNTIME_HEADERS__;
|
|
return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {};
|
|
};
|
|
|
|
export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken();
|
|
|
|
export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
|
|
const normalized = normalizeBearerToken(token);
|
|
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
|
clearRuntimeUrlAuthToken();
|
|
return;
|
|
}
|
|
const previous = runtimeUrlAuthToken;
|
|
runtimeUrlAuthToken = normalized;
|
|
runtimeUrlAuthTokenExpiresAt = expiresAt;
|
|
// Notify only on a real replacement (existing token swapped for a fresh one),
|
|
// not on the initial mint, so consumers remount token-bearing assets only
|
|
// when the URL token actually changed underneath them.
|
|
if (previous && previous !== normalized) {
|
|
notifyRuntimeUrlAuthListeners();
|
|
}
|
|
};
|
|
|
|
export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
|
|
const normalized = normalizeBearerToken(token);
|
|
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
|
localRuntimeUrlAuthToken = '';
|
|
localRuntimeUrlAuthTokenExpiresAt = 0;
|
|
return;
|
|
}
|
|
localRuntimeUrlAuthToken = normalized;
|
|
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
|
};
|
|
|
|
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
|
if (!runtimeUrlAuthToken || runtimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) {
|
|
clearRuntimeUrlAuthToken();
|
|
return '';
|
|
}
|
|
return runtimeUrlAuthToken;
|
|
};
|
|
|
|
const readValidLocalRuntimeUrlAuthTokenSync = (): string => {
|
|
if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) {
|
|
localRuntimeUrlAuthToken = '';
|
|
localRuntimeUrlAuthTokenExpiresAt = 0;
|
|
return '';
|
|
}
|
|
return localRuntimeUrlAuthToken;
|
|
};
|
|
|
|
export const getRuntimeUrlAuthTokenSync = (): string => {
|
|
const token = readValidRuntimeUrlAuthTokenSync();
|
|
if (!token && (getRuntimeBearerTokenSync() || typeof window !== 'undefined')) {
|
|
void refreshRuntimeUrlAuthToken().catch(() => {});
|
|
}
|
|
return token;
|
|
};
|
|
|
|
export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => {
|
|
const token = readValidLocalRuntimeUrlAuthTokenSync();
|
|
if (!token && localOrigin && typeof window !== 'undefined') {
|
|
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
|
|
}
|
|
return token;
|
|
};
|
|
|
|
const getRuntimeAuthCredential = async (): Promise<RuntimeAuthCredential> => {
|
|
const credential = await credentialProvider();
|
|
const token = credential?.type === 'bearer'
|
|
? normalizeBearerToken(credential.token)
|
|
: getRuntimeBearerTokenSync();
|
|
return token ? { type: 'bearer', token } : null;
|
|
};
|
|
|
|
// Performs the actual network mint and swaps the new token in atomically (the
|
|
// previous token stays valid until `setRuntimeUrlAuthToken` replaces it — no
|
|
// empty-token window). Concurrent callers share one in-flight request.
|
|
const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> => {
|
|
if (runtimeUrlAuthRefreshPromise) return runtimeUrlAuthRefreshPromise;
|
|
const generation = runtimeAuthGeneration;
|
|
|
|
const refreshPromise = (async () => {
|
|
const credential = await getRuntimeAuthCredential();
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
|
|
headers.set(key, value);
|
|
}
|
|
if (credential?.type === 'bearer') {
|
|
headers.set('Authorization', `Bearer ${credential.token}`);
|
|
}
|
|
const response = await fetch(buildAuthUrl(apiBaseUrl, '/auth/url-token'), {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
if (!response.ok) {
|
|
if (generation === runtimeAuthGeneration) {
|
|
clearRuntimeUrlAuthToken();
|
|
}
|
|
throw new Error(`Failed to mint runtime URL auth token (${response.status})`);
|
|
}
|
|
const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null;
|
|
const token = typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0;
|
|
if (generation !== runtimeAuthGeneration) {
|
|
throw new Error('Runtime URL auth token response is stale');
|
|
}
|
|
setRuntimeUrlAuthToken(token, expiresAt);
|
|
if (!runtimeUrlAuthToken) {
|
|
throw new Error('Runtime URL auth token response was invalid');
|
|
}
|
|
return runtimeUrlAuthToken;
|
|
})();
|
|
const trackedPromise = refreshPromise.finally(() => {
|
|
if (runtimeUrlAuthRefreshPromise === trackedPromise) {
|
|
runtimeUrlAuthRefreshPromise = null;
|
|
}
|
|
});
|
|
runtimeUrlAuthRefreshPromise = trackedPromise;
|
|
|
|
return runtimeUrlAuthRefreshPromise;
|
|
};
|
|
|
|
const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
|
|
if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise;
|
|
const refreshPromise = (async () => {
|
|
const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!response.ok) {
|
|
localRuntimeUrlAuthToken = '';
|
|
localRuntimeUrlAuthTokenExpiresAt = 0;
|
|
throw new Error(`Failed to mint local runtime URL auth token (${response.status})`);
|
|
}
|
|
const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null;
|
|
const token = typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0;
|
|
if (!token || !Number.isFinite(expiresAt)) {
|
|
throw new Error('Local runtime URL auth token response was invalid');
|
|
}
|
|
localRuntimeUrlAuthToken = token;
|
|
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
|
return token;
|
|
})();
|
|
const trackedPromise = refreshPromise.finally(() => {
|
|
if (localRuntimeUrlAuthRefreshPromise === trackedPromise) {
|
|
localRuntimeUrlAuthRefreshPromise = null;
|
|
}
|
|
});
|
|
localRuntimeUrlAuthRefreshPromise = trackedPromise;
|
|
return localRuntimeUrlAuthRefreshPromise;
|
|
};
|
|
|
|
// Returns a valid token without a network call, minting only when the current
|
|
// token is missing or already inside the skew window.
|
|
export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise<string> => {
|
|
const existing = readValidRuntimeUrlAuthTokenSync();
|
|
if (existing) return existing;
|
|
return mintRuntimeUrlAuthToken(apiBaseUrl);
|
|
};
|
|
|
|
export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise<string> => {
|
|
const existing = readValidLocalRuntimeUrlAuthTokenSync();
|
|
if (existing) return existing;
|
|
return mintLocalRuntimeUrlAuthToken(localOrigin);
|
|
};
|
|
|
|
// ── Proactive URL auth token refresh ──────────────────────────────────────
|
|
// The url token has a short server TTL. Instead of each consumer minting on its
|
|
// own timer (and clearing the shared token, which 401s other consumers during
|
|
// the refetch), a single scheduler refreshes it just before the skew window —
|
|
// but only while at least one consumer is active, so we never poll
|
|
// /auth/url-token in the background when nothing needs the token.
|
|
let urlAuthConsumerCount = 0;
|
|
let urlAuthRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let urlAuthApiBaseUrl: string | null = null;
|
|
const urlAuthListeners = new Set<() => void>();
|
|
const URL_AUTH_PROACTIVE_BUFFER_MS = 5_000;
|
|
|
|
const notifyRuntimeUrlAuthListeners = (): void => {
|
|
for (const listener of urlAuthListeners) {
|
|
try {
|
|
listener();
|
|
} catch {
|
|
// A listener throwing must not break the refresh loop.
|
|
}
|
|
}
|
|
};
|
|
|
|
const clearUrlAuthRefreshTimer = (): void => {
|
|
if (urlAuthRefreshTimer !== null) {
|
|
clearTimeout(urlAuthRefreshTimer);
|
|
urlAuthRefreshTimer = null;
|
|
}
|
|
};
|
|
|
|
const scheduleUrlAuthRefresh = (): void => {
|
|
clearUrlAuthRefreshTimer();
|
|
if (urlAuthConsumerCount <= 0 || typeof window === 'undefined') return;
|
|
|
|
// Refresh before the skew window so the old token is still valid when the new
|
|
// one swaps in. With no token yet (expiry 0), refresh immediately.
|
|
const refreshAt = runtimeUrlAuthTokenExpiresAt - URL_AUTH_REFRESH_SKEW_MS - URL_AUTH_PROACTIVE_BUFFER_MS;
|
|
const delay = runtimeUrlAuthTokenExpiresAt > 0 ? Math.max(0, refreshAt - Date.now()) : 0;
|
|
|
|
urlAuthRefreshTimer = setTimeout(() => {
|
|
urlAuthRefreshTimer = null;
|
|
if (urlAuthConsumerCount <= 0) return;
|
|
void mintRuntimeUrlAuthToken(urlAuthApiBaseUrl)
|
|
.catch(() => {
|
|
// Transient — the reschedule below retries (token is cleared on
|
|
// failure → expiry 0 → delay 0 → prompt retry).
|
|
})
|
|
.finally(() => {
|
|
scheduleUrlAuthRefresh();
|
|
});
|
|
}, delay);
|
|
};
|
|
|
|
// Register an active url-token consumer. While any consumer is held, the token
|
|
// is proactively refreshed before it expires. Returns a release function;
|
|
// the proactive loop stops once the last consumer releases.
|
|
export const acquireRuntimeUrlAuthToken = (apiBaseUrl?: string | null): (() => void) => {
|
|
if (typeof apiBaseUrl === 'string' && apiBaseUrl.trim()) {
|
|
urlAuthApiBaseUrl = apiBaseUrl.trim();
|
|
}
|
|
urlAuthConsumerCount += 1;
|
|
scheduleUrlAuthRefresh();
|
|
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
urlAuthConsumerCount = Math.max(0, urlAuthConsumerCount - 1);
|
|
if (urlAuthConsumerCount === 0) {
|
|
clearUrlAuthRefreshTimer();
|
|
}
|
|
};
|
|
};
|
|
|
|
// Subscribe to url-token *replacements* (an existing token swapped for a fresh
|
|
// one). Fires only on a real change — not the initial mint — so consumers can
|
|
// remount token-bearing assets without churning on first load. Returns an
|
|
// unsubscribe function.
|
|
export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) => {
|
|
urlAuthListeners.add(listener);
|
|
return () => {
|
|
urlAuthListeners.delete(listener);
|
|
};
|
|
};
|
|
|
|
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
|
|
const next = new Headers(headers);
|
|
for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) {
|
|
if (!next.has(key)) next.set(key, value);
|
|
}
|
|
if (next.has('Authorization')) {
|
|
return next;
|
|
}
|
|
|
|
const credential = await getRuntimeAuthCredential();
|
|
if (credential?.type === 'bearer') {
|
|
next.set('Authorization', `Bearer ${credential.token}`);
|
|
}
|
|
return next;
|
|
};
|