fix: enable connection link generation for desktop app
This commit is contained in:
@@ -46,7 +46,8 @@ import {
|
|||||||
resolveDesktopHostUrl,
|
resolveDesktopHostUrl,
|
||||||
type DesktopHost,
|
type DesktopHost,
|
||||||
} from '@/lib/desktopHosts';
|
} from '@/lib/desktopHosts';
|
||||||
import { isDesktopShell } from '@/lib/desktop';
|
import { getDesktopLanAddress, isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
||||||
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
const randomPort = (): number => {
|
const randomPort = (): number => {
|
||||||
@@ -229,6 +230,55 @@ const readRequestHeaderDrafts = (headers: Record<string, string> | undefined): H
|
|||||||
return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value));
|
return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRuntimePort = (): number | null => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeApiBaseUrl = getRuntimeApiBaseUrl();
|
||||||
|
const portSource = runtimeApiBaseUrl || window.location.href;
|
||||||
|
try {
|
||||||
|
const port = Number(new URL(portSource).port || window.location.port);
|
||||||
|
return Number.isFinite(port) && port > 0 ? port : null;
|
||||||
|
} catch {
|
||||||
|
const port = Number(window.location.port);
|
||||||
|
return Number.isFinite(port) && port > 0 ? port : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePairingServerUrl = async (): Promise<string> => {
|
||||||
|
const fallback = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
|
||||||
|
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await runtimeFetch('/api/config/settings', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (!response.ok) return fallback;
|
||||||
|
|
||||||
|
const settings = (await response.json().catch(() => null)) as null | {
|
||||||
|
desktopLanAccessActive?: unknown;
|
||||||
|
};
|
||||||
|
if (settings?.desktopLanAccessActive !== true) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = await getDesktopLanAddress();
|
||||||
|
const port = getRuntimePort();
|
||||||
|
if (!address || !port) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `http://${address}:${port}`;
|
||||||
|
};
|
||||||
|
|
||||||
const navigateToUrl = (rawUrl: string): void => {
|
const navigateToUrl = (rawUrl: string): void => {
|
||||||
const target = rawUrl.trim();
|
const target = rawUrl.trim();
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -549,7 +599,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
|||||||
if (!clientAuth) return;
|
if (!clientAuth) return;
|
||||||
setRemoteClientError(null);
|
setRemoteClientError(null);
|
||||||
try {
|
try {
|
||||||
const serverUrl = normalizeHostUrl(getRuntimeApiBaseUrl()) || window.location.origin;
|
const serverUrl = await resolvePairingServerUrl();
|
||||||
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
|
const result = await clientAuth.createClient({ label: remoteClientLabel.trim() || 'Paired client' });
|
||||||
const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
|
const payload = buildClientConnectionPayload({ serverUrl, token: result.token, label: remoteClientLabel || 'OpenChamber' });
|
||||||
const encoded = encodeClientConnectionPayload(payload);
|
const encoded = encodeClientConnectionPayload(payload);
|
||||||
|
|||||||
@@ -396,6 +396,35 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runWithClientCreateAuth = async (req, res, next, handler) => {
|
||||||
|
try {
|
||||||
|
if (typeof uiAuthController.resolveAuthContext === 'function') {
|
||||||
|
const context = await uiAuthController.resolveAuthContext(req, res, {
|
||||||
|
allowClientAuth: true,
|
||||||
|
allowUrlToken: false,
|
||||||
|
});
|
||||||
|
if (context?.type === 'session') {
|
||||||
|
await handler(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context?.type === 'client') {
|
||||||
|
const client = await clientRecordFromAuthContext(context);
|
||||||
|
if (client?.clientKind === 'desktop-local') {
|
||||||
|
await handler({ ...context, client });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return res.status(403).json({ error: 'Client tokens cannot create remote clients' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithUiAuth(req, res, next, async () => {
|
||||||
|
await handler({ type: 'session' });
|
||||||
|
}, { sessionOnly: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clientIdFromAuthContext = (context) => {
|
const clientIdFromAuthContext = (context) => {
|
||||||
const raw = context?.client?.id || context?.clientId;
|
const raw = context?.client?.id || context?.clientId;
|
||||||
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
||||||
@@ -567,7 +596,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
|
app.post('/api/client-auth/clients', express.json({ limit: '64kb' }), async (req, res, next) => {
|
||||||
await runWithUiAuth(req, res, next, async () => {
|
await runWithClientCreateAuth(req, res, next, async () => {
|
||||||
const result = await remoteClientAuthRuntime.createClient({
|
const result = await remoteClientAuthRuntime.createClient({
|
||||||
label: req.body?.label,
|
label: req.body?.label,
|
||||||
clientKind: req.body?.clientKind,
|
clientKind: req.body?.clientKind,
|
||||||
@@ -575,7 +604,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
|||||||
});
|
});
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
}, { sessionOnly: true });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
|
app.delete('/api/client-auth/clients/:id', async (req, res, next) => {
|
||||||
|
|||||||
@@ -399,6 +399,36 @@ describe('client auth routes', () => {
|
|||||||
expect(revoked.body.client.id).toBe(current.body.client.id);
|
expect(revoked.body.client.id).toBe(current.body.client.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows only the local desktop client token to create remote client tokens', async () => {
|
||||||
|
const app = express();
|
||||||
|
let authContext = { type: 'session' };
|
||||||
|
const dependencies = createDependencies({
|
||||||
|
resolveAuthContext: async () => authContext,
|
||||||
|
});
|
||||||
|
registerAuthAndAccessRoutes(app, dependencies);
|
||||||
|
|
||||||
|
const desktop = await request(app)
|
||||||
|
.post('/api/client-auth/clients')
|
||||||
|
.send({ label: 'OpenChamber Desktop', clientKind: 'desktop-local' });
|
||||||
|
const remote = await request(app)
|
||||||
|
.post('/api/client-auth/clients')
|
||||||
|
.send({ label: 'Phone' });
|
||||||
|
|
||||||
|
authContext = { type: 'client', clientId: remote.body.client.id, client: remote.body.client };
|
||||||
|
const denied = await request(app)
|
||||||
|
.post('/api/client-auth/clients')
|
||||||
|
.send({ label: 'Another phone' });
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
expect(denied.body.error).toBe('Client tokens cannot create remote clients');
|
||||||
|
|
||||||
|
authContext = { type: 'client', clientId: desktop.body.client.id, client: desktop.body.client };
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/client-auth/clients')
|
||||||
|
.send({ label: 'Mobile' });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
expect(created.body.client.label).toBe('Mobile');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires UI-session auth for passkey registration management routes', async () => {
|
it('requires UI-session auth for passkey registration management routes', async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
const dependencies = createDependencies();
|
const dependencies = createDependencies();
|
||||||
|
|||||||
Reference in New Issue
Block a user