Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -8,6 +8,8 @@ import { createUiPasskeys } from './ui-passkeys.js';
|
||||
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const TRUSTED_DEVICE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const URL_AUTH_TOKEN_TTL_MS = 60 * 1000;
|
||||
const URL_AUTH_TOKEN_PREFIX = 'oc_url_';
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
||||
@@ -243,6 +245,74 @@ const parseCookies = (cookieHeader) => {
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getBearerTokenFromRequest = (req) => {
|
||||
const header = req?.headers?.authorization;
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/^Bearer\s+(.+)$/i);
|
||||
const token = match?.[1]?.trim() || '';
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getUrlAuthTokenFromRequest = (req) => {
|
||||
const queryToken = req?.query?.oc_url_token;
|
||||
let token = Array.isArray(queryToken) ? queryToken[0] : queryToken;
|
||||
if (typeof token !== 'string' && typeof req?.url === 'string') {
|
||||
try {
|
||||
token = new URL(req.url, 'http://localhost').searchParams.get('oc_url_token') || undefined;
|
||||
} catch {
|
||||
token = undefined;
|
||||
}
|
||||
}
|
||||
return typeof token === 'string' && token.trim() ? token.trim() : null;
|
||||
};
|
||||
|
||||
const getRequestPathname = (req) => {
|
||||
if (typeof req?.path === 'string' && req.path) return req.path;
|
||||
const rawUrl = req?.originalUrl || req?.url;
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) return '';
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isWebSocketUpgrade = (req) => {
|
||||
const upgrade = req?.headers?.upgrade;
|
||||
const upgradeValue = Array.isArray(upgrade) ? upgrade[0] : upgrade;
|
||||
return String(upgradeValue || '').toLowerCase() === 'websocket';
|
||||
};
|
||||
|
||||
const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
return pathname === '/api/event'
|
||||
|| pathname === '/api/global/event'
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| pathname === '/api/fs/raw'
|
||||
|| pathname.startsWith('/api/preview/proxy/')
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
||||
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
||||
};
|
||||
|
||||
const isUrlAuthWebSocketPath = (pathname) => {
|
||||
return pathname === '/api/event/ws'
|
||||
|| pathname === '/api/global/event/ws'
|
||||
|| pathname === '/api/terminal/ws'
|
||||
|| pathname.startsWith('/api/preview/proxy/');
|
||||
};
|
||||
|
||||
const canUseUrlAuthTokenForRequest = (req) => {
|
||||
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
|
||||
const pathname = getRequestPathname(req);
|
||||
if (isWebSocketUpgrade(req)) {
|
||||
return isUrlAuthWebSocketPath(pathname);
|
||||
}
|
||||
return method === 'GET' && isUrlAuthReadableHttpPath(pathname);
|
||||
};
|
||||
|
||||
const buildCookie = ({
|
||||
name,
|
||||
value,
|
||||
@@ -330,8 +400,79 @@ export const createUiAuth = ({
|
||||
cookieName = SESSION_COOKIE_NAME,
|
||||
sessionTtlMs = SESSION_TTL_MS,
|
||||
readSettingsFromDiskMigrated,
|
||||
clientAuthController = null,
|
||||
requireClientAuth = false,
|
||||
} = {}) => {
|
||||
const normalizedPassword = normalizePassword(password);
|
||||
const urlAuthTokens = new Map();
|
||||
|
||||
const sweepUrlAuthTokens = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of urlAuthTokens.entries()) {
|
||||
if (!entry || entry.expiresAt <= now) {
|
||||
urlAuthTokens.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issueUrlAuthTokenForSession = (sessionToken) => {
|
||||
sweepUrlAuthTokens();
|
||||
const token = `${URL_AUTH_TOKEN_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
|
||||
const expiresAt = Date.now() + URL_AUTH_TOKEN_TTL_MS;
|
||||
urlAuthTokens.set(token, { sessionToken, expiresAt });
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
const authenticateUrlAuthToken = (req) => {
|
||||
if (!canUseUrlAuthTokenForRequest(req)) return null;
|
||||
const token = getUrlAuthTokenFromRequest(req);
|
||||
if (!token || !token.startsWith(URL_AUTH_TOKEN_PREFIX)) return null;
|
||||
const entry = urlAuthTokens.get(token);
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
urlAuthTokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
return { ok: true, sessionToken: entry.sessionToken || 'url:authenticated' };
|
||||
};
|
||||
|
||||
const authenticateClientRequest = async (req, { allowUrlToken = true } = {}) => {
|
||||
if (allowUrlToken) {
|
||||
const urlAuth = authenticateUrlAuthToken(req);
|
||||
if (urlAuth) return urlAuth;
|
||||
}
|
||||
const token = getBearerTokenFromRequest(req);
|
||||
if (!token || typeof clientAuthController?.authenticateBearerToken !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = await clientAuthController.authenticateBearerToken(token, req);
|
||||
if (result?.ok) {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clientSessionToken = (clientAuth) => {
|
||||
const raw = clientAuth?.sessionToken || clientAuth?.clientId || clientAuth?.id;
|
||||
if (typeof raw === 'string' && (raw.startsWith('client:') || raw.startsWith('url:'))) return raw;
|
||||
return typeof raw === 'string' && raw.length > 0 ? `client:${raw}` : 'client:authenticated';
|
||||
};
|
||||
|
||||
const clientAuthClientId = (clientAuth) => {
|
||||
const raw = clientAuth?.client?.id || clientAuth?.clientId || clientAuth?.id || clientAuth?.sessionToken;
|
||||
if (typeof raw !== 'string' || raw.length === 0) return null;
|
||||
return raw.startsWith('client:') ? raw.slice('client:'.length) : raw;
|
||||
};
|
||||
|
||||
const clientAuthContext = (clientAuth) => ({
|
||||
type: 'client',
|
||||
token: clientSessionToken(clientAuth),
|
||||
clientId: clientAuthClientId(clientAuth),
|
||||
client: clientAuth?.client || null,
|
||||
});
|
||||
|
||||
if (!normalizedPassword) {
|
||||
const setSessionCookie = (req, res, token, ttlMs = sessionTtlMs) => {
|
||||
@@ -356,15 +497,77 @@ export const createUiAuth = ({
|
||||
return token;
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
if (!requireClientAuth) {
|
||||
return next();
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
};
|
||||
|
||||
const requireSessionAuth = async (req, res, next) => {
|
||||
if (!requireClientAuth) {
|
||||
return next();
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'UI session authentication required', locked: true });
|
||||
};
|
||||
|
||||
const resolveAuthContext = async (req, res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
if (cookies[cookieName]) {
|
||||
return { type: 'session', token: cookies[cookieName] };
|
||||
}
|
||||
if (allowClientAuth) {
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
if (clientAuth) return clientAuthContext(clientAuth);
|
||||
}
|
||||
if (!requireClientAuth) {
|
||||
const token = await ensureSessionToken(req, res);
|
||||
return { type: 'session', token };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
requireAuth: (_req, _res, next) => next(),
|
||||
handleSessionStatus: (_req, res) => {
|
||||
requireAuth,
|
||||
requireSessionAuth,
|
||||
resolveAuthContext,
|
||||
handleSessionStatus: async (req, res) => {
|
||||
if (requireClientAuth) {
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return res.json({ authenticated: true, disabled: true, scope: 'client' });
|
||||
}
|
||||
return res.status(401).json({ authenticated: false, locked: true, clientAuthRequired: true });
|
||||
}
|
||||
res.json({ authenticated: true, disabled: true });
|
||||
},
|
||||
handleSessionCreate: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
handleUrlAuthToken: async (req, res) => {
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken: false });
|
||||
if (clientAuth) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(clientSessionToken(clientAuth)));
|
||||
}
|
||||
if (requireClientAuth) {
|
||||
return res.status(401).json({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
}
|
||||
const sessionToken = await ensureSessionToken(req, res);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
||||
},
|
||||
handlePasskeyStatus: (_req, res) => {
|
||||
res.json({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null });
|
||||
},
|
||||
@@ -389,7 +592,11 @@ export const createUiAuth = ({
|
||||
handleResetAuth: (_req, res) => {
|
||||
res.status(400).json({ error: 'UI password not configured' });
|
||||
},
|
||||
ensureSessionToken,
|
||||
ensureSessionToken: async (req, res) => {
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) return clientSessionToken(clientAuth);
|
||||
return ensureSessionToken(req, res);
|
||||
},
|
||||
dispose: () => {
|
||||
|
||||
},
|
||||
@@ -418,6 +625,7 @@ export const createUiAuth = ({
|
||||
const rotateJwtSecret = () => {
|
||||
const nextSecret = crypto.randomBytes(32).toString('hex');
|
||||
jwtSecret = persistJwtSecret(nextSecret);
|
||||
urlAuthTokens.clear();
|
||||
rebuildPasskeyController();
|
||||
};
|
||||
|
||||
@@ -496,7 +704,7 @@ export const createUiAuth = ({
|
||||
const respondUnauthorized = (req, res) => {
|
||||
res.status(401);
|
||||
const acceptsJson = req.headers.accept?.includes('application/json');
|
||||
if (acceptsJson || req.path.startsWith('/api')) {
|
||||
if (acceptsJson || req.path?.startsWith('/api')) {
|
||||
res.json({ error: 'UI authentication required', locked: true });
|
||||
} else {
|
||||
res.type('text/plain').send('Authentication required');
|
||||
@@ -504,6 +712,22 @@ export const createUiAuth = ({
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return next();
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
return next();
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
};
|
||||
|
||||
const requireSessionAuth = async (req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return next();
|
||||
}
|
||||
@@ -521,10 +745,44 @@ export const createUiAuth = ({
|
||||
res.json({ authenticated: true });
|
||||
return;
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req);
|
||||
if (clientAuth) {
|
||||
res.json({ authenticated: true, scope: 'client' });
|
||||
return;
|
||||
}
|
||||
clearSessionCookie(req, res);
|
||||
res.status(401).json({ authenticated: false, locked: true });
|
||||
};
|
||||
|
||||
const resolveAuthenticatedSessionToken = async (req, { allowUrlToken = true } = {}) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return token;
|
||||
}
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
return clientAuth ? clientSessionToken(clientAuth) : null;
|
||||
};
|
||||
|
||||
const resolveAuthContext = async (req, _res, { allowClientAuth = true, allowUrlToken = true } = {}) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (await isSessionValid(token)) {
|
||||
return { type: 'session', token };
|
||||
}
|
||||
if (!allowClientAuth) return null;
|
||||
const clientAuth = await authenticateClientRequest(req, { allowUrlToken });
|
||||
return clientAuth ? clientAuthContext(clientAuth) : null;
|
||||
};
|
||||
|
||||
const handleUrlAuthToken = async (req, res) => {
|
||||
const sessionToken = await resolveAuthenticatedSessionToken(req, { allowUrlToken: false });
|
||||
if (!sessionToken) {
|
||||
clearSessionCookie(req, res);
|
||||
return respondUnauthorized(req, res);
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json(issueUrlAuthTokenForSession(sessionToken));
|
||||
};
|
||||
|
||||
const handleSessionCreate = async (req, res) => {
|
||||
const rateLimitResult = await checkRateLimit(req);
|
||||
|
||||
@@ -551,10 +809,23 @@ export const createUiAuth = ({
|
||||
|
||||
await clearRateLimit(req);
|
||||
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
|
||||
const ttlMs = resolveSessionTtlMs(trustDevice);
|
||||
await issueSession(req, res, { trustDevice });
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
authenticated: true,
|
||||
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
};
|
||||
|
||||
const respondPasskeyError = (res, error) => {
|
||||
@@ -601,10 +872,22 @@ export const createUiAuth = ({
|
||||
const handlePasskeyAuthenticationVerify = async (req, res) => {
|
||||
try {
|
||||
await passkeyController.finishAuthentication(req.body);
|
||||
await issueSession(req, res, {
|
||||
trustDevice: isTrustedDeviceRequest(req.body?.trustDevice),
|
||||
const trustDevice = isTrustedDeviceRequest(req.body?.trustDevice);
|
||||
const ttlMs = resolveSessionTtlMs(trustDevice);
|
||||
await issueSession(req, res, { trustDevice });
|
||||
let clientTokenResult = null;
|
||||
if (req.body?.issueClientToken === true && typeof clientAuthController?.createClient === 'function') {
|
||||
clientTokenResult = await clientAuthController.createClient({
|
||||
label: req.body?.clientLabel,
|
||||
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||||
clientKind: req.body?.clientKind,
|
||||
dedupeKey: req.body?.dedupeKey,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
authenticated: true,
|
||||
...(clientTokenResult?.token ? { clientToken: clientTokenResult.token, client: clientTokenResult.client } : {}),
|
||||
});
|
||||
res.json({ authenticated: true });
|
||||
} catch (error) {
|
||||
respondPasskeyError(res, error);
|
||||
}
|
||||
@@ -654,8 +937,11 @@ export const createUiAuth = ({
|
||||
return {
|
||||
enabled: true,
|
||||
requireAuth,
|
||||
requireSessionAuth,
|
||||
resolveAuthContext,
|
||||
handleSessionStatus,
|
||||
handleSessionCreate,
|
||||
handleUrlAuthToken,
|
||||
handlePasskeyStatus,
|
||||
handlePasskeyRegistrationOptions,
|
||||
handlePasskeyRegistrationVerify,
|
||||
@@ -665,8 +951,7 @@ export const createUiAuth = ({
|
||||
handlePasskeyRevoke,
|
||||
handleResetAuth,
|
||||
ensureSessionToken: async (req, _res) => {
|
||||
const token = getTokenFromRequest(req);
|
||||
return (await isSessionValid(token)) ? token : null;
|
||||
return resolveAuthenticatedSessionToken(req);
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ui-auth-test-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = dataDir;
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const loadCreateUiAuth = async () => {
|
||||
const module = await import('./ui-auth.js');
|
||||
return module.createUiAuth;
|
||||
};
|
||||
|
||||
const createResponse = () => {
|
||||
let statusCode = 200;
|
||||
let body = null;
|
||||
const headers = new Map();
|
||||
return {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
body = payload;
|
||||
return this;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
get body() {
|
||||
return body;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers.get(name.toLowerCase());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('ui auth client credential seam', () => {
|
||||
it('accepts bearer client credentials when UI password auth is enabled', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const req = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const res = createResponse();
|
||||
let called = false;
|
||||
|
||||
await auth.requireAuth(req, res, () => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
expect(called).toBe(true);
|
||||
expect(await auth.ensureSessionToken(req, res)).toBe('client:device-1');
|
||||
expect(await auth.resolveAuthContext(req, res, { allowUrlToken: false })).toMatchObject({
|
||||
type: 'client',
|
||||
clientId: 'device-1',
|
||||
token: 'client:device-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not accept bearer client credentials for UI-session-only auth', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const clientReq = { method: 'GET', path: '/api/client-auth/clients', headers: { authorization: 'Bearer client-token' } };
|
||||
const clientRes = createResponse();
|
||||
let clientCalled = false;
|
||||
await auth.requireSessionAuth(clientReq, clientRes, () => {
|
||||
clientCalled = true;
|
||||
});
|
||||
expect(clientCalled).toBe(false);
|
||||
expect(clientRes.statusCode).toBe(401);
|
||||
|
||||
const loginReq = { method: 'POST', headers: {}, body: { password: 'secret' } };
|
||||
const loginRes = createResponse();
|
||||
await auth.handleSessionCreate(loginReq, loginRes);
|
||||
const sessionCookie = String(loginRes.getHeader('set-cookie') || '').split(';', 1)[0];
|
||||
expect(sessionCookie.startsWith('oc_ui_session=')).toBe(true);
|
||||
|
||||
const sessionReq = { method: 'GET', path: '/api/client-auth/clients', headers: { cookie: sessionCookie } };
|
||||
const sessionRes = createResponse();
|
||||
let sessionCalled = false;
|
||||
await auth.requireSessionAuth(sessionReq, sessionRes, () => {
|
||||
sessionCalled = true;
|
||||
});
|
||||
expect(sessionCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('can require bearer client credentials when UI password is disabled', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
requireClientAuth: true,
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, sessionToken: 'remote-session' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedReq = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const allowedRes = createResponse();
|
||||
let called = false;
|
||||
await auth.requireAuth(allowedReq, allowedRes, () => {
|
||||
called = true;
|
||||
});
|
||||
expect(called).toBe(true);
|
||||
expect(await auth.ensureSessionToken(allowedReq, allowedRes)).toBe('client:remote-session');
|
||||
|
||||
const deniedReq = { method: 'GET', headers: {} };
|
||||
const deniedRes = createResponse();
|
||||
await auth.requireAuth(deniedReq, deniedRes, () => {});
|
||||
expect(deniedRes.statusCode).toBe(401);
|
||||
expect(deniedRes.body).toEqual({ error: 'Client authentication required', locked: true, clientAuthRequired: true });
|
||||
});
|
||||
|
||||
it('reports authenticated client session status with bearer credentials', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
const req = { method: 'GET', headers: { authorization: 'Bearer client-token' } };
|
||||
const res = createResponse();
|
||||
|
||||
await auth.handleSessionStatus(req, res);
|
||||
|
||||
expect(res.body).toEqual({ authenticated: true, scope: 'client' });
|
||||
});
|
||||
|
||||
it('exchanges bearer credentials for short-lived URL auth tokens', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
clientAuthController: {
|
||||
authenticateBearerToken: async (token) => token === 'client-token' ? { ok: true, clientId: 'device-1' } : null,
|
||||
},
|
||||
});
|
||||
|
||||
const oldQueryReq = { method: 'GET', path: '/api/config/settings', url: '/api/config/settings?oc_client_token=client-token', headers: { accept: 'application/json' } };
|
||||
const oldQueryRes = createResponse();
|
||||
let oldQueryCalled = false;
|
||||
await auth.requireAuth(oldQueryReq, oldQueryRes, () => {
|
||||
oldQueryCalled = true;
|
||||
});
|
||||
expect(oldQueryCalled).toBe(false);
|
||||
expect(oldQueryRes.statusCode).toBe(401);
|
||||
|
||||
const mintReq = { method: 'POST', path: '/auth/url-token', headers: { authorization: 'Bearer client-token', accept: 'application/json' } };
|
||||
const mintRes = createResponse();
|
||||
await auth.handleUrlAuthToken(mintReq, mintRes);
|
||||
expect(typeof mintRes.body.token).toBe('string');
|
||||
expect(mintRes.body.token.startsWith('oc_url_')).toBe(true);
|
||||
expect(mintRes.body.expiresAt).toBeGreaterThan(Date.now());
|
||||
expect(mintRes.getHeader('cache-control')).toBe('no-store');
|
||||
|
||||
const urlToken = mintRes.body.token;
|
||||
const urlReq = { method: 'GET', path: '/api/fs/raw', url: `/api/fs/raw?path=%2Ftmp%2Fimage.png&oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||
const urlRes = createResponse();
|
||||
let urlCalled = false;
|
||||
await auth.requireAuth(urlReq, urlRes, () => {
|
||||
urlCalled = true;
|
||||
});
|
||||
expect(urlCalled).toBe(true);
|
||||
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
||||
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
||||
|
||||
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const arbitraryGetRes = createResponse();
|
||||
let arbitraryGetCalled = false;
|
||||
await auth.requireAuth(arbitraryGetReq, arbitraryGetRes, () => {
|
||||
arbitraryGetCalled = true;
|
||||
});
|
||||
expect(arbitraryGetCalled).toBe(false);
|
||||
expect(arbitraryGetRes.statusCode).toBe(401);
|
||||
|
||||
const postReq = { method: 'POST', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const postRes = createResponse();
|
||||
let postCalled = false;
|
||||
await auth.requireAuth(postReq, postRes, () => {
|
||||
postCalled = true;
|
||||
});
|
||||
expect(postCalled).toBe(false);
|
||||
expect(postRes.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('issues desktop client tokens with the UI session expiry', async () => {
|
||||
const createUiAuth = await loadCreateUiAuth();
|
||||
let createClientInput = null;
|
||||
const auth = createUiAuth({
|
||||
password: 'secret',
|
||||
sessionTtlMs: 123_000,
|
||||
clientAuthController: {
|
||||
createClient: async (input) => {
|
||||
createClientInput = input;
|
||||
return {
|
||||
token: 'client-token',
|
||||
client: {
|
||||
id: 'device-1',
|
||||
label: input.label,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
const req = {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: {
|
||||
password: 'secret',
|
||||
issueClientToken: true,
|
||||
clientLabel: 'OpenChamber Desktop',
|
||||
},
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await auth.handleSessionCreate(req, res);
|
||||
|
||||
expect(res.body.clientToken).toBe('client-token');
|
||||
expect(createClientInput.label).toBe('OpenChamber Desktop');
|
||||
const expiresAt = Date.parse(createClientInput.expiresAt);
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 122_000);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 124_000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user