feat(desktop): proxy realtime requests with runtime headers
This commit is contained in:
@@ -10,6 +10,9 @@ 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;
|
||||
@@ -58,6 +61,8 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri
|
||||
export const clearRuntimeUrlAuthToken = (): void => {
|
||||
runtimeUrlAuthToken = '';
|
||||
runtimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
};
|
||||
|
||||
const resetRuntimeAuthGeneration = (): void => {
|
||||
@@ -120,6 +125,17 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -128,6 +144,15 @@ const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
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')) {
|
||||
@@ -136,6 +161,14 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
|
||||
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'
|
||||
@@ -193,6 +226,37 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
|
||||
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> => {
|
||||
@@ -201,6 +265,12 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getRuntimeUrlResolver,
|
||||
setRuntimeUrlResolver,
|
||||
} from './runtime-url';
|
||||
import { setRuntimeBearerToken, setRuntimeUrlAuthToken } from './runtime-auth';
|
||||
import { setLocalRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders, setRuntimeUrlAuthToken } from './runtime-auth';
|
||||
|
||||
describe('createRuntimeUrlResolver', () => {
|
||||
const withWindow = <T>(value: unknown, callback: () => T): T => {
|
||||
@@ -76,6 +76,54 @@ describe('createRuntimeUrlResolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('routes realtime URLs through local desktop proxy when runtime headers are configured', () => {
|
||||
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
|
||||
try {
|
||||
withWindow({
|
||||
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
|
||||
__OPENCHAMBER_API_BASE_URL__: 'https://remote.example',
|
||||
__OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123',
|
||||
}, () => {
|
||||
const urls = createRuntimeUrlResolver({});
|
||||
const sse = new URL(urls.sse('/api/global/event'));
|
||||
const ws = new URL(urls.websocket('/api/global/event/ws'));
|
||||
|
||||
expect(sse.origin).toBe('http://127.0.0.1:57123');
|
||||
expect(sse.pathname).toBe('/api/openchamber/realtime-proxy/sse');
|
||||
expect(sse.searchParams.get('url')).toBe('https://remote.example/api/global/event');
|
||||
expect(ws.origin).toBe('ws://127.0.0.1:57123');
|
||||
expect(ws.pathname).toBe('/api/openchamber/realtime-proxy/ws');
|
||||
expect(ws.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws');
|
||||
});
|
||||
} finally {
|
||||
setRuntimeExtraHeaders(null);
|
||||
}
|
||||
});
|
||||
|
||||
test('adds local URL auth token to desktop realtime proxy URL', () => {
|
||||
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
|
||||
setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000);
|
||||
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000);
|
||||
try {
|
||||
withWindow({
|
||||
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
|
||||
__OPENCHAMBER_API_BASE_URL__: 'https://remote.example',
|
||||
__OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123',
|
||||
}, () => {
|
||||
const urls = createRuntimeUrlResolver({});
|
||||
const sse = new URL(urls.sse('/api/global/event'));
|
||||
const target = new URL(sse.searchParams.get('url') || '');
|
||||
|
||||
expect(sse.searchParams.get('oc_url_token')).toBe('local-url-token');
|
||||
expect(target.searchParams.get('oc_url_token')).toBe('remote-url-token');
|
||||
});
|
||||
} finally {
|
||||
setRuntimeExtraHeaders(null);
|
||||
setRuntimeUrlAuthToken(null, null);
|
||||
setLocalRuntimeUrlAuthToken(null, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('reads injected desktop API base URL at call time', () => {
|
||||
withWindow({
|
||||
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth';
|
||||
import { getLocalRuntimeUrlAuthTokenSync, getRuntimeExtraHeadersSync, getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth';
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
@@ -39,6 +39,14 @@ const readInjectedApiBaseUrl = (): string => {
|
||||
return normalizeBaseUrl(injected);
|
||||
};
|
||||
|
||||
const readInjectedLocalOrigin = (): string => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
|
||||
return normalizeBaseUrl(injected);
|
||||
};
|
||||
|
||||
const hasRuntimeExtraHeaders = (): boolean => Object.keys(getRuntimeExtraHeadersSync()).length > 0;
|
||||
|
||||
const currentHref = (config: RuntimeUrlConfig): string => {
|
||||
const configured = config.currentHref?.();
|
||||
if (configured) return configured;
|
||||
@@ -110,6 +118,25 @@ const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string =>
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const toRealtimeProxyUrl = (kind: 'sse' | 'ws', targetUrl: string, config: RuntimeUrlConfig): string | null => {
|
||||
if (!hasRuntimeExtraHeaders()) return null;
|
||||
const localOrigin = readInjectedLocalOrigin();
|
||||
if (!localOrigin) return null;
|
||||
try {
|
||||
const proxy = new URL(`/api/openchamber/realtime-proxy/${kind === 'sse' ? 'sse' : 'ws'}`, `${localOrigin}/`);
|
||||
proxy.searchParams.set('url', targetUrl);
|
||||
const localToken = getLocalRuntimeUrlAuthTokenSync(localOrigin);
|
||||
if (localToken) proxy.searchParams.set('oc_url_token', localToken);
|
||||
if (kind === 'ws') {
|
||||
proxy.protocol = proxy.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return toWebSocketUrl(proxy.toString(), config);
|
||||
}
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => {
|
||||
const configuredApiBaseUrl = normalizeBaseUrl(config.apiBaseUrl);
|
||||
const configuredRealtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl);
|
||||
@@ -131,8 +158,14 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime
|
||||
allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined,
|
||||
outsideFileGrant: options?.outsideFileGrant,
|
||||
}),
|
||||
sse: (path, query) => withUrlAuth(realtime(path, query)),
|
||||
websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config),
|
||||
sse: (path, query) => {
|
||||
const target = withUrlAuth(realtime(path, query));
|
||||
return toRealtimeProxyUrl('sse', target, config) || target;
|
||||
},
|
||||
websocket: (path, query) => {
|
||||
const target = toWebSocketUrl(withUrlAuth(realtime(path, query)), config);
|
||||
return toRealtimeProxyUrl('ws', target, config) || target;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user