From f8e4cfe136d251f49958d6e16322722d9a579907 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:23:51 -0400 Subject: [PATCH 1/6] feat(device-pairing): pair phones and browsers with a QR code Settings -> Account gets "Connect a device", a paired-devices list, and a remote access headers editor, so the mobile app and other browsers can sign in without typing a password and pass a reverse proxy such as Cloudflare Access. - one-time pairing codes (3 min TTL) minted by a Better Auth plugin and redeemed into a new session that records the device name - POST /api/device-pairing/links builds the ocm://link app link with the stored headers as h= parameters and a browser /pair link; plain-HTTP and loopback origins are blocked or warned about - remote access headers are stored encrypted, returned by name only, and validated (8 headers, 512-char values, 1800 encoded characters, reserved transport names blocked); a Cloudflare Access service token preset fills CF-Access-Client-Id and CF-Access-Client-Secret - login offers "Sign in with QR code" and /pair redeems a scanned code, returning to the login redirect target - revoking a device deletes its session, closes its live connections and removes its push subscriptions; the current session cannot be revoked --- backend/src/auth/device-pairing.ts | 170 +++++++ backend/src/auth/index.ts | 14 + .../202610221800-session-device-name.ts | 20 + ...2610231200-push-subscription-session-id.ts | 14 + backend/src/db/migrations/index.ts | 4 + backend/src/db/push-subscriptions.ts | 32 ++ backend/src/db/secrets.ts | 4 + backend/src/index.ts | 8 + backend/src/routes/device-pairing.ts | 73 +++ backend/src/routes/notifications.ts | 11 +- .../src/routes/opencode-server-terminals.ts | 7 +- backend/src/routes/remote-access-headers.ts | 41 ++ backend/src/routes/repo-terminal-socket.ts | 7 +- backend/src/routes/settings.ts | 3 + backend/src/routes/sse.ts | 22 +- backend/src/services/device-pairing-links.ts | 63 +++ backend/src/services/notification.ts | 35 +- backend/src/services/preview/gateway.ts | 5 +- backend/src/services/remote-access-headers.ts | 104 +++++ backend/src/services/session-connections.ts | 41 ++ backend/src/utils/websocket-bridge.ts | 19 + backend/test/auth/device-pairing.test.ts | 424 ++++++++++++++++++ backend/test/auth/index.test.ts | 14 +- .../test/auth/session-delete-hooks.test.ts | 174 +++++++ ...-subscription-session-id-migration.test.ts | 73 +++ backend/test/helpers/auth-database.ts | 13 + backend/test/helpers/log-assertions.ts | 24 + backend/test/helpers/remote-access-headers.ts | 11 + backend/test/helpers/websocket-server.ts | 2 +- backend/test/routes/device-pairing.test.ts | 260 +++++++++++ backend/test/routes/notifications.test.ts | 35 ++ .../opencode-server-terminal-socket.test.ts | 56 +++ .../test/routes/remote-access-headers.test.ts | 220 +++++++++ .../test/routes/repo-terminal-socket.test.ts | 99 ++++ backend/test/routes/settings.test.ts | 9 + backend/test/routes/sse.test.ts | 88 ++++ .../notification-subscriptions.test.ts | 123 +++++ backend/test/services/preview/gateway.test.ts | 19 + .../test/services/session-connections.test.ts | 101 +++++ .../shared/device-pairing-schemas.test.ts | 230 ++++++++++ backend/test/shared/manager-app-link.test.ts | 111 +++++ backend/test/shared/trusted-origin.test.ts | 38 ++ backend/test/shared/url-hosts.test.ts | 50 +++ backend/test/shared/user-agent.test.ts | 50 +++ frontend/package.json | 1 + frontend/src/App.tsx | 7 +- frontend/src/api/devicePairing.ts | 80 ++++ .../components/settings/AccountSettings.tsx | 9 + .../settings/ConnectDeviceDialog.test.tsx | 234 ++++++++++ .../settings/ConnectDeviceDialog.tsx | 216 +++++++++ .../settings/ConnectDeviceSettings.test.tsx | 82 ++++ .../settings/ConnectDeviceSettings.tsx | 56 +++ .../settings/OpenCodeServerDialog.test.tsx | 23 + .../settings/OpenCodeServerDialog.tsx | 24 +- .../settings/PairedDevicesSettings.test.tsx | 115 +++++ .../settings/PairedDevicesSettings.tsx | 113 +++++ .../settings/PairingQrCode.test.tsx | 22 + .../src/components/settings/PairingQrCode.tsx | 51 +++ .../src/components/settings/QrCodeScanner.tsx | 38 +- .../RemoteAccessHeadersSettings.test.tsx | 173 +++++++ .../settings/RemoteAccessHeadersSettings.tsx | 296 ++++++++++++ .../components/settings/SettingsDialog.tsx | 2 +- frontend/src/contexts/AuthContext.test.tsx | 121 ++++- frontend/src/contexts/AuthContext.tsx | 26 ++ frontend/src/hooks/useDevicePairing.ts | 81 ++++ frontend/src/lib/auth-client.ts | 4 +- frontend/src/pages/Login.tsx | 49 +- frontend/src/pages/Pair.tsx | 109 +++++ frontend/src/pages/__tests__/Login.test.tsx | 130 ++++++ frontend/src/pages/__tests__/Pair.test.tsx | 117 +++++ pnpm-lock.yaml | 3 + shared/src/config/env.ts | 11 + shared/src/schemas/device-pairing.ts | 204 +++++++++ shared/src/schemas/index.ts | 1 + shared/src/schemas/opencode-servers.ts | 105 ++++- shared/src/schemas/settings.ts | 7 + shared/src/utils/index.ts | 1 + shared/src/utils/url.ts | 44 ++ shared/src/utils/user-agent.ts | 32 ++ 79 files changed, 5398 insertions(+), 110 deletions(-) create mode 100644 backend/src/auth/device-pairing.ts create mode 100644 backend/src/db/migrations/202610221800-session-device-name.ts create mode 100644 backend/src/db/migrations/202610231200-push-subscription-session-id.ts create mode 100644 backend/src/db/push-subscriptions.ts create mode 100644 backend/src/routes/device-pairing.ts create mode 100644 backend/src/routes/remote-access-headers.ts create mode 100644 backend/src/services/device-pairing-links.ts create mode 100644 backend/src/services/remote-access-headers.ts create mode 100644 backend/src/services/session-connections.ts create mode 100644 backend/test/auth/device-pairing.test.ts create mode 100644 backend/test/auth/session-delete-hooks.test.ts create mode 100644 backend/test/db/push-subscription-session-id-migration.test.ts create mode 100644 backend/test/helpers/auth-database.ts create mode 100644 backend/test/helpers/log-assertions.ts create mode 100644 backend/test/helpers/remote-access-headers.ts create mode 100644 backend/test/routes/device-pairing.test.ts create mode 100644 backend/test/routes/remote-access-headers.test.ts create mode 100644 backend/test/services/notification-subscriptions.test.ts create mode 100644 backend/test/services/session-connections.test.ts create mode 100644 backend/test/shared/device-pairing-schemas.test.ts create mode 100644 backend/test/shared/manager-app-link.test.ts create mode 100644 backend/test/shared/trusted-origin.test.ts create mode 100644 backend/test/shared/url-hosts.test.ts create mode 100644 backend/test/shared/user-agent.test.ts create mode 100644 frontend/src/api/devicePairing.ts create mode 100644 frontend/src/components/settings/ConnectDeviceDialog.test.tsx create mode 100644 frontend/src/components/settings/ConnectDeviceDialog.tsx create mode 100644 frontend/src/components/settings/ConnectDeviceSettings.test.tsx create mode 100644 frontend/src/components/settings/ConnectDeviceSettings.tsx create mode 100644 frontend/src/components/settings/PairedDevicesSettings.test.tsx create mode 100644 frontend/src/components/settings/PairedDevicesSettings.tsx create mode 100644 frontend/src/components/settings/PairingQrCode.test.tsx create mode 100644 frontend/src/components/settings/PairingQrCode.tsx create mode 100644 frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx create mode 100644 frontend/src/components/settings/RemoteAccessHeadersSettings.tsx create mode 100644 frontend/src/hooks/useDevicePairing.ts create mode 100644 frontend/src/pages/Pair.tsx create mode 100644 frontend/src/pages/__tests__/Login.test.tsx create mode 100644 frontend/src/pages/__tests__/Pair.test.tsx create mode 100644 shared/src/schemas/device-pairing.ts create mode 100644 shared/src/utils/user-agent.ts diff --git a/backend/src/auth/device-pairing.ts b/backend/src/auth/device-pairing.ts new file mode 100644 index 000000000..54b1f6861 --- /dev/null +++ b/backend/src/auth/device-pairing.ts @@ -0,0 +1,170 @@ +import { createHash, randomBytes } from 'node:crypto' +import type { BetterAuthPlugin } from 'better-auth' +import { APIError, createAuthEndpoint, sessionMiddleware } from 'better-auth/api' +import { setSessionCookie } from 'better-auth/cookies' +import { z } from 'zod' +import { + DEVICE_PAIRING_CODE_TTL_SECONDS, + DEVICE_PAIRING_ERROR_CODES, + PAIRING_CODE_INVALID_MESSAGE, + RedeemDevicePairingCodeRequestSchema, + type PairedDevice, +} from '@opencode-manager/shared/schemas' +import { logger } from '../utils/logger' + +const PAIRING_IDENTIFIER_PREFIX = 'device-pairing:' +const REDEEM_PATH = '/device-pairing/redeem' +const DEVICES_PATH = '/device-pairing/devices' +const REVOKE_PATH = '/device-pairing/devices/revoke' + +type SessionRecord = { + id: string + createdAt: Date + updatedAt: Date + ipAddress?: string | null + userAgent?: string | null + deviceName?: string | null +} + +function toPairedDevice(session: SessionRecord, currentSessionId: string): PairedDevice { + return { + id: session.id, + deviceName: session.deviceName ?? null, + userAgent: session.userAgent || null, + ipAddress: session.ipAddress || null, + createdAt: new Date(session.createdAt).toISOString(), + updatedAt: new Date(session.updatedAt).toISOString(), + isCurrent: session.id === currentSessionId, + } +} + +function pairingIdentifier(code: string): string { + return `${PAIRING_IDENTIFIER_PREFIX}${createHash('sha256').update(code).digest('base64url')}` +} + +function invalidPairingCodeError(): APIError { + return new APIError('BAD_REQUEST', { + message: PAIRING_CODE_INVALID_MESSAGE, + code: DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID, + }) +} + +export interface DevicePairingOptions { + onDeviceRevoked?: (sessionId: string) => void +} + +/** Adds pairing code, paired device, and revoke endpoints to Better Auth. */ +export function devicePairing(options: DevicePairingOptions = {}) { + const { onDeviceRevoked } = options + return { + id: 'device-pairing', + schema: { + session: { + fields: { + deviceName: { type: 'string', required: false, input: false }, + }, + }, + }, + endpoints: { + createDevicePairingCode: createAuthEndpoint.serverOnly( + { method: 'POST', use: [sessionMiddleware], requireHeaders: true }, + async (ctx) => { + const code = randomBytes(32).toString('base64url') + const expiresAt = new Date(Date.now() + DEVICE_PAIRING_CODE_TTL_SECONDS * 1000) + + await ctx.context.adapter.deleteMany({ + model: 'verification', + where: [ + { field: 'identifier', value: PAIRING_IDENTIFIER_PREFIX, operator: 'starts_with' }, + { field: 'expiresAt', value: new Date(), operator: 'lt' }, + ], + }) + await ctx.context.internalAdapter.createVerificationValue({ + identifier: pairingIdentifier(code), + value: ctx.context.session.user.id, + expiresAt, + }) + + return ctx.json({ code, expiresAt }) + }, + ), + redeemDevicePairingCode: createAuthEndpoint( + REDEEM_PATH, + { + method: 'POST', + body: RedeemDevicePairingCodeRequestSchema, + metadata: { noStore: true }, + }, + async (ctx) => { + const { code, deviceName } = ctx.body + const record = await ctx.context.internalAdapter.consumeVerificationValue(pairingIdentifier(code)) + if (!record) throw invalidPairingCodeError() + + const user = await ctx.context.internalAdapter.findUserById(record.value) + if (!user) throw invalidPairingCodeError() + + const session = await ctx.context.internalAdapter.createSession( + user.id, + false, + deviceName ? { deviceName } : undefined, + ) + await setSessionCookie(ctx, { session, user }) + + return ctx.json({ user: { id: user.id, name: user.name, email: user.email } }) + }, + ), + listPairedDevices: createAuthEndpoint( + DEVICES_PATH, + { method: 'GET', use: [sessionMiddleware], requireHeaders: true }, + async (ctx) => { + const currentSessionId = ctx.context.session.session.id + const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, { + onlyActiveSessions: true, + }) + const devices = sessions + .map((session) => toPairedDevice(session, currentSessionId)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + + return ctx.json({ devices }) + }, + ), + revokePairedDevice: createAuthEndpoint( + REVOKE_PATH, + { + method: 'POST', + body: z.object({ id: z.string().min(1) }), + use: [sessionMiddleware], + requireHeaders: true, + }, + async (ctx) => { + const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, { + onlyActiveSessions: true, + }) + const target = sessions.find((session) => session.id === ctx.body.id) + if (!target) { + throw new APIError('NOT_FOUND', { + message: 'This device is no longer paired', + code: DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND, + }) + } + if (target.id === ctx.context.session.session.id) { + throw new APIError('BAD_REQUEST', { + message: 'The current device cannot be revoked', + code: DEVICE_PAIRING_ERROR_CODES.CANNOT_REVOKE_CURRENT_SESSION, + }) + } + + await ctx.context.internalAdapter.deleteSession(target.token) + if (onDeviceRevoked) { + try { + onDeviceRevoked(target.id) + } catch (error) { + logger.error(`Failed to delete push subscriptions for revoked session ${target.id}`, error) + } + } + return ctx.json({ status: true }) + }, + ), + }, + } satisfies BetterAuthPlugin +} diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts index 1732f74bb..006f5cc82 100644 --- a/backend/src/auth/index.ts +++ b/backend/src/auth/index.ts @@ -5,6 +5,9 @@ import { Database } from 'bun:sqlite' import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env' import { AUTH_COOKIE_PREFIX } from './cookies' import { AUTH_SOCKET_IP_HEADER } from './client-ip' +import { devicePairing } from './device-pairing' +import { closeSessionConnections } from '../services/session-connections' +import { deletePushSubscriptionsForSession } from '../db/push-subscriptions' export type AuthInstance = ReturnType @@ -53,6 +56,13 @@ export function createAuth(db: Database) { }, socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, databaseHooks: { + session: { + delete: { + after: async (session) => { + closeSessionConnections(session.id) + }, + }, + }, user: { create: { before: async (user, context) => { @@ -73,6 +83,9 @@ export function createAuth(db: Database) { userVerification: 'required', }, }), + devicePairing({ + onDeviceRevoked: (sessionId) => deletePushSubscriptionsForSession(db, sessionId), + }), ], session: { expiresIn: 60 * 60 * 24 * 7, @@ -110,6 +123,7 @@ export type Session = { updatedAt: Date ipAddress?: string | null userAgent?: string | null + deviceName?: string | null } user: { id: string diff --git a/backend/src/db/migrations/202610221800-session-device-name.ts b/backend/src/db/migrations/202610221800-session-device-name.ts new file mode 100644 index 000000000..d00bc5dc0 --- /dev/null +++ b/backend/src/db/migrations/202610221800-session-device-name.ts @@ -0,0 +1,20 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + id: '202610221800-session-device-name', + up(db) { + const tableInfo = db.prepare('PRAGMA table_info("session")').all() as ColumnInfo[] + if (!tableInfo.some((column) => column.name === 'deviceName')) { + db.run('ALTER TABLE "session" ADD COLUMN deviceName TEXT') + } + }, + down(db) { + void db + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610231200-push-subscription-session-id.ts b/backend/src/db/migrations/202610231200-push-subscription-session-id.ts new file mode 100644 index 000000000..bea2fdfa9 --- /dev/null +++ b/backend/src/db/migrations/202610231200-push-subscription-session-id.ts @@ -0,0 +1,14 @@ +import type { Migration } from '../migration-runner' +import { ensurePushSubscriptionsTable } from '../../db/push-subscriptions' + +const migration: Migration = { + id: '202610231200-push-subscription-session-id', + up(db) { + ensurePushSubscriptionsTable(db) + }, + down(db) { + void db + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 56feebfbf..813f8ea29 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -39,6 +39,8 @@ import migration202610181310 from './202610181310-multi-run-targets' import migration202610181320 from './202610181320-remote-schedule-targets' import migration202610181400 from './202610181400-secrets' import { secretsGlobalScopeIndexMigration } from './202610181410-secrets-global-scope-index' +import migration202610221800 from './202610221800-session-device-name' +import migration202610231200 from './202610231200-push-subscription-session-id' export const allMigrations: Migration[] = [ migration001, @@ -81,4 +83,6 @@ export const allMigrations: Migration[] = [ migration202610181320, migration202610181400, secretsGlobalScopeIndexMigration, + migration202610221800, + migration202610231200, ] diff --git a/backend/src/db/push-subscriptions.ts b/backend/src/db/push-subscriptions.ts new file mode 100644 index 000000000..7471ded34 --- /dev/null +++ b/backend/src/db/push-subscriptions.ts @@ -0,0 +1,32 @@ +import type { Database } from 'bun:sqlite' + +/** Creates the push subscriptions schema and adds the columns older databases lack. */ +export function ensurePushSubscriptionsTable(db: Database): void { + db.run(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + device_name TEXT, + session_id TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) + `) + + const columns = db.prepare('PRAGMA table_info(push_subscriptions)').all() as Array<{ name: string }> + if (!columns.some((column) => column.name === 'session_id')) { + db.run('ALTER TABLE push_subscriptions ADD COLUMN session_id TEXT') + } + + db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)') + db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)') + db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_session ON push_subscriptions(session_id)') +} + +/** Deletes the push subscriptions a revoked Better Auth session registered. */ +export function deletePushSubscriptionsForSession(db: Database, sessionId: string): number { + return db.prepare('DELETE FROM push_subscriptions WHERE session_id = ?').run(sessionId).changes +} diff --git a/backend/src/db/secrets.ts b/backend/src/db/secrets.ts index 50c539389..c1562a64b 100644 --- a/backend/src/db/secrets.ts +++ b/backend/src/db/secrets.ts @@ -25,3 +25,7 @@ export function writeEncryptedSecret(db: Database, key: string, value: string): export function deleteSecret(db: Database, key: string): void { db.prepare('DELETE FROM app_secrets WHERE key = ?').run(key) } + +export function hasSecret(db: Database, key: string): boolean { + return Boolean(db.prepare('SELECT 1 FROM app_secrets WHERE key = ?').get(key)) +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 189800ccc..9921facac 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import { createRepoTerminalSocketRoutes } from './routes/repo-terminal-socket' import { createIPCServer, type IPCServer } from './ipc/ipcServer' import { GitAuthService } from './services/git-auth' import { createSettingsRoutes } from './routes/settings' +import { createDevicePairingRoutes } from './routes/device-pairing' import { createHealthRoutes } from './routes/health' import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'; import { createSTTRoutes } from './routes/stt' @@ -62,6 +63,7 @@ import { sseAggregator } from './services/sse-aggregator' import { RemoteServerEventHub } from './services/remote-server-events' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' import { SettingsService } from './services/settings' +import { RemoteAccessHeadersService } from './services/remote-access-headers' import { SessionPermissionModeService } from './services/session-permission-modes' import { SessionSandboxService } from './services/session-sandbox' import { SessionAncestry } from './services/session-ancestry' @@ -400,6 +402,12 @@ protectedApi.use('/*', requireAuth) protectedApi.route('/repos', createRepoTerminalSocketRoutes(db, gitAuthService, openCodeClient, terminalService, upgradeWebSocket)) protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, terminalService, projectConfigService, repoWorkspaces, secretsService)) protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor)) +protectedApi.route('/device-pairing', createDevicePairingRoutes({ + mintPairingCode: (headers) => auth.api.createDevicePairingCode({ headers }), + remoteAccessHeaders: new RemoteAccessHeadersService(db), + readAllowPrivateNetworkHttp: () => + settingsService.getSettings('default').preferences.devicePairing?.allowPrivateNetworkHttp === true, +})) protectedApi.route('/files', createFileRoutes()) protectedApi.route('/filesystem', createFilesystemRoutes()) protectedApi.route('/providers', createProvidersRoutes(openCodeClient)) diff --git a/backend/src/routes/device-pairing.ts b/backend/src/routes/device-pairing.ts new file mode 100644 index 000000000..e31284925 --- /dev/null +++ b/backend/src/routes/device-pairing.ts @@ -0,0 +1,73 @@ +import { Hono } from 'hono' +import { ENV, getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env' +import { CreateDevicePairingLinksRequestSchema } from '@opencode-manager/shared/schemas' +import { buildDevicePairingLinks, resolveAppLinkPolicy } from '../services/device-pairing-links' +import type { RemoteAccessHeadersService } from '../services/remote-access-headers' +import { logger } from '../utils/logger' +import { parseJsonBody } from '../utils/route-helpers' + +const MISSING_ORIGIN_MESSAGE = + 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.' +const LINKS_FAILED_MESSAGE = 'Failed to create device pairing links' + +function untrustedOriginMessage(origin: string | undefined): string { + if (!origin) return MISSING_ORIGIN_MESSAGE + return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.` +} + +interface DevicePairingRouteDeps { + mintPairingCode(headers: Headers): Promise<{ code: string; expiresAt: Date | string }> + remoteAccessHeaders: RemoteAccessHeadersService + readAllowPrivateNetworkHttp(): boolean + resolveTrustedOrigins?: () => string[] + isDevelopment?: () => boolean +} + +/** Creates the routes that mint device pairing links for the Manager origin. */ +export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) { + const app = new Hono() + const resolveTrustedOrigins = deps.resolveTrustedOrigins ?? getTrustedOrigins + const isDevelopment = deps.isDevelopment ?? (() => ENV.SERVER.NODE_ENV === 'development') + + app.use('/links', async (c, next) => { + await next() + c.header('Cache-Control', 'no-store') + }) + + app.post('/links', async (c) => { + const body = await parseJsonBody(c, CreateDevicePairingLinksRequestSchema, { allowEmpty: true }) + if (!body.ok) return body.response + + const origin = c.req.header('origin') + if (!origin || !isTrustedOrigin(origin, { requireOrigin: true }, resolveTrustedOrigins())) { + return c.json({ error: untrustedOriginMessage(origin) }, 400) + } + + try { + const originUrl = new URL(origin) + const policy = resolveAppLinkPolicy(originUrl, { + allowPrivateNetworkHttp: deps.readAllowPrivateNetworkHttp(), + isDevelopment: isDevelopment(), + }) + const stored = deps.remoteAccessHeaders.read() + const minted = await deps.mintPairingCode(c.req.raw.headers) + + return c.json( + buildDevicePairingLinks({ + origin: originUrl, + name: body.data.name ?? originUrl.hostname, + code: minted.code, + expiresAt: new Date(minted.expiresAt), + headers: stored.headers, + headersUnreadable: stored.unreadable, + policy, + }), + ) + } catch (error) { + logger.error(LINKS_FAILED_MESSAGE, error instanceof Error ? error.message : 'unknown') + return c.json({ error: LINKS_FAILED_MESSAGE }, 500) + } + }) + + return app +} diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index ad44ae788..08852a95c 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -2,11 +2,16 @@ import { Hono } from "hono"; import { z } from "zod"; import { PushSubscriptionRequestSchema } from "@opencode-manager/shared/schemas"; import type { NotificationService } from "../services/notification"; +import type { Session } from "../auth"; + +interface NotificationRouteVariables { + session?: Session['session']; +} export function createNotificationRoutes( notificationService: NotificationService ) { - const app = new Hono(); + const app = new Hono<{ Variables: NotificationRouteVariables }>(); app.get("/vapid-public-key", (c) => { const publicKey = notificationService.getVapidPublicKey(); @@ -21,6 +26,7 @@ export function createNotificationRoutes( app.post("/subscribe", async (c) => { const userId = c.req.query('userId') || 'default' + const sessionId = c.get('session')?.id const body = await c.req.json(); const parsed = PushSubscriptionRequestSchema.safeParse(body); @@ -35,7 +41,8 @@ export function createNotificationRoutes( endpoint, keys.p256dh, keys.auth, - deviceName + deviceName, + sessionId ); return c.json({ subscription }); diff --git a/backend/src/routes/opencode-server-terminals.ts b/backend/src/routes/opencode-server-terminals.ts index fa0a1d9af..b25ca8394 100644 --- a/backend/src/routes/opencode-server-terminals.ts +++ b/backend/src/routes/opencode-server-terminals.ts @@ -12,6 +12,7 @@ import { TerminalNotFoundError, type TerminalService } from '../services/termina import { parseTerminalCursor } from '../utils/terminal-cursor' import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' import { handleOpenCodeError, handleServiceError, parseJsonBody } from '../utils/route-helpers' +import type { Session } from '../auth' export type ServerTerminalServiceResolver = (serverId: string) => TerminalService @@ -19,6 +20,7 @@ interface ServerTerminalSocketVariables { terminalService: TerminalService terminalDirectory: string terminalCursor: number | undefined + session?: Session['session'] } function isAbsoluteDirectory(directory: string): boolean { @@ -181,7 +183,10 @@ export function createOpenCodeServerTerminalSocketRoutes( const cursor = c.get('terminalCursor') const ptyID = c.req.param('ptyID') - return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer)) + return createBridgedSocketEvents( + (peer) => terminalService.connect(directory, ptyID, cursor, peer), + { sessionId: c.get('session')?.id }, + ) }), ) diff --git a/backend/src/routes/remote-access-headers.ts b/backend/src/routes/remote-access-headers.ts new file mode 100644 index 000000000..d1f0532f2 --- /dev/null +++ b/backend/src/routes/remote-access-headers.ts @@ -0,0 +1,41 @@ +import { Hono } from 'hono' +import { ReplaceRemoteAccessHeadersRequestSchema } from '@opencode-manager/shared/schemas' +import { handleServiceError, parseJsonBody } from '../utils/route-helpers' +import { RemoteAccessHeadersError, type RemoteAccessHeadersService } from '../services/remote-access-headers' + +const READ_FAILED_MESSAGE = 'Failed to read remote access headers' +const WRITE_FAILED_MESSAGE = 'Failed to update remote access headers' + +export function createRemoteAccessHeaderRoutes(service: RemoteAccessHeadersService) { + const app = new Hono() + + app.get('/', (c) => { + try { + return c.json(service.getSummary()) + } catch (error) { + return handleServiceError(c, error, READ_FAILED_MESSAGE, RemoteAccessHeadersError) + } + }) + + app.put('/', async (c) => { + const body = await parseJsonBody(c, ReplaceRemoteAccessHeadersRequestSchema) + if (!body.ok) return body.response + + try { + return c.json(service.replace(body.data)) + } catch (error) { + return handleServiceError(c, error, WRITE_FAILED_MESSAGE, RemoteAccessHeadersError) + } + }) + + app.delete('/', (c) => { + try { + service.clear() + return c.json(service.getSummary()) + } catch (error) { + return handleServiceError(c, error, WRITE_FAILED_MESSAGE, RemoteAccessHeadersError) + } + }) + + return app +} diff --git a/backend/src/routes/repo-terminal-socket.ts b/backend/src/routes/repo-terminal-socket.ts index 486feec3a..6e7b458cb 100644 --- a/backend/src/routes/repo-terminal-socket.ts +++ b/backend/src/routes/repo-terminal-socket.ts @@ -9,10 +9,12 @@ import type { TerminalService } from '../services/terminal' import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' import { parseTerminalCursor } from '../utils/terminal-cursor' import { resolveRepoRequestDirectory } from './repo-directory' +import type { Session } from '../auth' interface RepoTerminalSocketVariables { terminalDirectory: string terminalCursor: number | undefined + session?: Session['session'] } export function createRepoTerminalSocketRoutes( @@ -54,7 +56,10 @@ export function createRepoTerminalSocketRoutes( const cursor = c.get('terminalCursor') const ptyID = c.req.param('ptyID') ?? '' - return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer)) + return createBridgedSocketEvents( + (peer) => terminalService.connect(directory, ptyID, cursor, peer), + { sessionId: c.get('session')?.id }, + ) }), ) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3ba3af2cd..2b0e833fa 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -7,7 +7,9 @@ import { SettingsService } from '../services/settings' import { writeFileContent, readFileContent, fileExists } from '../services/file-operations' import { archiveBrokenOpenCodeConfigFile, deleteOpenCodeConfigFile } from '../services/opencode-config-file' import { reloadOpenCodeOrMarkRestartPending, restoreLastKnownGoodOpenCodeConfig } from '../services/opencode-config-apply' +import { RemoteAccessHeadersService } from '../services/remote-access-headers' import { createOpenCodeConfigRoutes } from './opencode-config' +import { createRemoteAccessHeaderRoutes } from './remote-access-headers' import type { OpenCodeClient } from '../services/opencode/client' import { getAgentsMdPath } from '@opencode-manager/shared/config/env' import { @@ -476,6 +478,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic // OpenCode Config routes app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient)) + app.route('/remote-access-headers', createRemoteAccessHeaderRoutes(new RemoteAccessHeadersService(db))) app.post('/opencode-restart', async (c) => { try { diff --git a/backend/src/routes/sse.ts b/backend/src/routes/sse.ts index 2d4882ce2..093890ecc 100644 --- a/backend/src/routes/sse.ts +++ b/backend/src/routes/sse.ts @@ -5,11 +5,17 @@ import { LOCAL_OPENCODE_SERVER_ID, SSESubscribeSchema, SSEVisibilitySchema } fro import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' import { createQueuedSSEWriter } from './sse-writer' +import { registerSessionConnection } from '../services/session-connections' +import type { Session } from '../auth' const { HEARTBEAT_INTERVAL_MS } = DEFAULTS.SSE +interface SSERouteVariables { + session?: Session['session'] +} + export function createSSERoutes() { - const app = new Hono() + const app = new Hono<{ Variables: SSERouteVariables }>() app.get('/stream', async (c) => { const directoriesParam = c.req.query('directories') @@ -48,15 +54,27 @@ export function createSSERoutes() { directories ) + let endStream: () => void = () => {} + const streamEnded = new Promise((resolve) => { + endStream = resolve + }) + + const sessionId = c.get('session')?.id + const unregisterSession = sessionId === undefined + ? () => {} + : registerSessionConnection(sessionId, () => writer.abort()) + writer.onAbort(() => { + unregisterSession() queuedWriter.close() clearInterval(heartbeatInterval) cleanup() + endStream() }) queuedWriter.writeSSE('connected', JSON.stringify({ clientId, directories, ...sseAggregator.getConnectionStatus() })) - await new Promise(() => {}) + await streamEnded }) }) diff --git a/backend/src/services/device-pairing-links.ts b/backend/src/services/device-pairing-links.ts new file mode 100644 index 000000000..893aa2ff8 --- /dev/null +++ b/backend/src/services/device-pairing-links.ts @@ -0,0 +1,63 @@ +import { + buildManagerAppLink, + buildManagerBrowserPairingLink, + type AppLinkBlockedReason, + type DevicePairingLinkWarning, + type DevicePairingLinksResponse, + type RemoteAccessHeader, +} from '@opencode-manager/shared/schemas' +import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils' + +type AppLinkPolicy = { + blockedReason: AppLinkBlockedReason | null + warnings: DevicePairingLinkWarning[] +} + +/** Decides whether an origin can carry an app link and which warnings it raises. */ +export function resolveAppLinkPolicy( + origin: URL, + options: { allowPrivateNetworkHttp: boolean; isDevelopment: boolean }, +): AppLinkPolicy { + const warnings: DevicePairingLinkWarning[] = [] + const isPlainHttp = origin.protocol === 'http:' + + if (isPlainHttp) warnings.push('plain-http') + + if (isLoopbackHost(origin.hostname)) { + warnings.push('loopback') + return { blockedReason: options.isDevelopment ? null : 'loopback', warnings } + } + + if (isPlainHttp) { + const isAllowed = options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname) + return { blockedReason: isAllowed ? null : 'insecure-origin', warnings } + } + + return { blockedReason: null, warnings } +} + +/** Builds the pairing link response, dropping the app link when its origin is blocked. */ +export function buildDevicePairingLinks(input: { + origin: URL + name: string + code: string + expiresAt: Date + headers: RemoteAccessHeader[] + headersUnreadable: boolean + policy: AppLinkPolicy +}): DevicePairingLinksResponse { + const { origin, name, code, headers, policy } = input + const baseUrl = origin.origin + + return { + appLink: + policy.blockedReason === null + ? buildManagerAppLink({ kind: 'manager', name, baseUrl, code, headers }) + : null, + appLinkBlockedReason: policy.blockedReason, + browserLink: buildManagerBrowserPairingLink(baseUrl, code), + expiresAt: input.expiresAt.toISOString(), + headerNames: headers.map((header) => header.name), + warnings: input.headersUnreadable ? [...policy.warnings, 'headers-unreadable'] : policy.warnings, + } +} diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 1ae8b2a52..1596cfb2a 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -24,6 +24,7 @@ import { SettingsService } from "./settings"; import { sseAggregator, type SSEEvent } from "./sse-aggregator"; import { getRepoName } from "../db/queries"; import { getScheduleRunBySessionId } from "../db/schedules"; +import { ensurePushSubscriptionsTable } from "../db/push-subscriptions"; import type { Repo } from "../types/repo"; import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; @@ -183,28 +184,7 @@ export class NotificationService { private serverRegistry: NotificationServerRegistry | null = null ) { this.settingsService = new SettingsService(db); - this.initializePushSubscriptionsTable(); - } - - private initializePushSubscriptionsTable(): void { - this.db.run(` - CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - endpoint TEXT NOT NULL UNIQUE, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - device_name TEXT, - created_at INTEGER NOT NULL, - last_used_at INTEGER - ) - `); - this.db.run( - "CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)" - ); - this.db.run( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)" - ); + ensurePushSubscriptionsTable(db); } configureVapid(config: VapidConfig): void { @@ -229,22 +209,24 @@ export class NotificationService { endpoint: string, p256dh: string, auth: string, - deviceName?: string + deviceName?: string, + sessionId?: string | null ): PushSubscriptionRecord { const now = Date.now(); this.db .prepare( - `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, session_id, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, device_name = excluded.device_name, + session_id = excluded.session_id, last_used_at = excluded.last_used_at` ) - .run(userId, endpoint, p256dh, auth, deviceName ?? null, now, now); + .run(userId, endpoint, p256dh, auth, deviceName ?? null, sessionId ?? null, now, now); const row = this.db .prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?") @@ -255,6 +237,7 @@ export class NotificationService { p256dh: string; auth: string; device_name: string | null; + session_id: string | null; created_at: number; last_used_at: number | null; }; diff --git a/backend/src/services/preview/gateway.ts b/backend/src/services/preview/gateway.ts index 6115dd73d..a96e2b15b 100644 --- a/backend/src/services/preview/gateway.ts +++ b/backend/src/services/preview/gateway.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono' import type { Context, MiddlewareHandler } from 'hono' import { createNodeWebSocket, type NodeWebSocket } from '@hono/node-ws' import type { PreviewPort } from '@opencode-manager/shared/schemas' +import { isLoopbackHost } from '@opencode-manager/shared/utils' import { AUTH_COOKIE_PREFIX } from '../../auth/cookies' import { buildProxyResponseHeaders, filterProxyHeaders } from '../../utils/proxy-headers' import { bridgeWebSocket, createBridgedSocketEvents } from '../../utils/websocket-bridge' @@ -135,9 +136,7 @@ function rewriteLocalLocation(location: string, port: number): string | null { } catch { return null } - const isLocalHost = - parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]' || parsed.hostname === '::1' - if (!isLocalHost) return null + if (!isLoopbackHost(parsed.hostname)) return null const locationPort = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === 'https:' diff --git a/backend/src/services/remote-access-headers.ts b/backend/src/services/remote-access-headers.ts new file mode 100644 index 000000000..cf6559cf5 --- /dev/null +++ b/backend/src/services/remote-access-headers.ts @@ -0,0 +1,104 @@ +import type { Database } from 'bun:sqlite' +import { + RemoteAccessHeaderListSchema, + type RemoteAccessHeader, + type RemoteAccessHeadersResponse, + type RemoteAccessHeaderSummary, + type ReplaceRemoteAccessHeadersRequest, +} from '@opencode-manager/shared/schemas' +import { deleteSecret, hasSecret, readEncryptedSecret, writeEncryptedSecret } from '../db/secrets' +import { logger } from '../utils/logger' +import { ServiceError } from '../utils/service-error' + +export const REMOTE_ACCESS_HEADERS_SECRET_KEY = 'remote_access_headers' + +export class RemoteAccessHeadersError extends ServiceError { + constructor(message: string, status: number) { + super(message, status) + this.name = 'RemoteAccessHeadersError' + } +} + +export interface StoredRemoteAccessHeaders { + headers: RemoteAccessHeader[] + unreadable: boolean +} + +function parseStoredHeaders(value: string): RemoteAccessHeader[] | null { + let parsedValue: unknown + try { + parsedValue = JSON.parse(value) + } catch { + return null + } + + const parsed = RemoteAccessHeaderListSchema.safeParse(parsedValue) + return parsed.success ? parsed.data : null +} + +function summarize(headers: RemoteAccessHeader[]): RemoteAccessHeaderSummary[] { + return headers.map((header) => ({ name: header.name, hasValue: true })) +} + +export class RemoteAccessHeadersService { + constructor(private db: Database) {} + + read(): StoredRemoteAccessHeaders { + const stored = readEncryptedSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY) + if (stored === null) { + return { headers: [], unreadable: hasSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY) } + } + + const headers = parseStoredHeaders(stored) + if (headers !== null) { + return { headers, unreadable: false } + } + + logger.warn('Stored remote access headers could not be read') + return { headers: [], unreadable: true } + } + + getSummary(): RemoteAccessHeadersResponse { + const { headers, unreadable } = this.read() + return { headers: summarize(headers), unreadable } + } + + replace(request: ReplaceRemoteAccessHeadersRequest): RemoteAccessHeadersResponse { + const stored = this.read().headers + + const merged = request.headers.map((entry) => { + const value = entry.value?.trim() + if (value) { + return { name: entry.name, value } + } + + const existing = stored.find((header) => header.name.toLowerCase() === entry.name.toLowerCase()) + if (!existing) { + throw new RemoteAccessHeadersError(`A value is required for ${entry.name}`, 400) + } + return { name: entry.name, value: existing.value } + }) + + const validated = RemoteAccessHeaderListSchema.safeParse(merged) + if (!validated.success) { + throw new RemoteAccessHeadersError( + validated.error.issues.map((issue) => issue.message).join('; '), + 400, + ) + } + + const headers = validated.data + if (headers.length === 0) { + deleteSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY) + } else { + writeEncryptedSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY, JSON.stringify(headers)) + } + + logger.info(`Updated remote access headers (${headers.length} headers)`) + return { headers: summarize(headers), unreadable: false } + } + + clear(): void { + deleteSecret(this.db, REMOTE_ACCESS_HEADERS_SECRET_KEY) + } +} diff --git a/backend/src/services/session-connections.ts b/backend/src/services/session-connections.ts new file mode 100644 index 000000000..128d8f9f6 --- /dev/null +++ b/backend/src/services/session-connections.ts @@ -0,0 +1,41 @@ +import { logger } from '../utils/logger' + +export type SessionConnectionCloser = () => void + +export const SESSION_CONNECTION_CLOSE_CODE = 1008 + +export const SESSION_CONNECTION_CLOSE_REASON = 'Session ended' + +const connectionsBySession = new Map>() + +export function registerSessionConnection(sessionId: string, close: SessionConnectionCloser): () => void { + let closers = connectionsBySession.get(sessionId) + if (!closers) { + closers = new Set() + connectionsBySession.set(sessionId, closers) + } + closers.add(close) + + return () => { + const current = connectionsBySession.get(sessionId) + if (!current) return + current.delete(close) + if (current.size === 0) { + connectionsBySession.delete(sessionId) + } + } +} + +export function closeSessionConnections(sessionId: string): void { + const closers = connectionsBySession.get(sessionId) + if (!closers) return + connectionsBySession.delete(sessionId) + + for (const close of closers) { + try { + close() + } catch (error) { + logger.error('Failed to close a session connection', { sessionId, error }) + } + } +} diff --git a/backend/src/utils/websocket-bridge.ts b/backend/src/utils/websocket-bridge.ts index 21945c623..9e74aef2c 100644 --- a/backend/src/utils/websocket-bridge.ts +++ b/backend/src/utils/websocket-bridge.ts @@ -1,5 +1,10 @@ import type { WSContext, WSEvents } from 'hono/ws' import { getErrorMessage } from './error-utils' +import { + registerSessionConnection, + SESSION_CONNECTION_CLOSE_CODE, + SESSION_CONNECTION_CLOSE_REASON, +} from '../services/session-connections' export interface WebSocketPeer { send(data: string | Uint8Array): void @@ -27,6 +32,10 @@ export interface ConnectUpstreamOptions { timeoutMs?: number } +export interface BridgedSocketEventsOptions { + sessionId?: string +} + export const MAX_PEER_BUFFERED_BYTES = 4 * 1024 * 1024 export const MAX_UPSTREAM_BUFFERED_BYTES = 4 * 1024 * 1024 @@ -320,13 +329,21 @@ export function connectUpstreamWebSocket( export function createBridgedSocketEvents( openBridge: (peer: WebSocketPeer) => WebSocketBridge | Promise, + options: BridgedSocketEventsOptions = {}, ): WSEvents { let bridge: WebSocketBridge | undefined let closed = false let connectPromise: Promise = Promise.resolve() + let unregisterSession: (() => void) | undefined return { onOpen(_event, ws) { + if (options.sessionId !== undefined) { + const sessionId = options.sessionId + unregisterSession = registerSessionConnection(sessionId, () => { + ws.close(SESSION_CONNECTION_CLOSE_CODE, SESSION_CONNECTION_CLOSE_REASON) + }) + } connectPromise = (async () => { const connected = await openBridge(toWebSocketPeer(ws)) if (closed) { @@ -346,6 +363,8 @@ export function createBridgedSocketEvents( }, onClose() { closed = true + unregisterSession?.() + unregisterSession = undefined bridge?.close() }, } diff --git a/backend/test/auth/device-pairing.test.ts b/backend/test/auth/device-pairing.test.ts new file mode 100644 index 000000000..d13cc7bc7 --- /dev/null +++ b/backend/test/auth/device-pairing.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash } from 'node:crypto' +import type { Database } from 'bun:sqlite' +import { + DEVICE_PAIRING_CODE_TTL_SECONDS, + DEVICE_PAIRING_ERROR_CODES, + PAIRING_CODE_INVALID_MESSAGE, + PairedDeviceSchema, +} from '@opencode-manager/shared/schemas' +import { createAuth, type AuthInstance } from '../../src/auth' +import { devicePairing } from '../../src/auth/device-pairing' +import { AUTH_SOCKET_IP_HEADER } from '../../src/auth/client-ip' +import { createAuthDatabase } from '../helpers/auth-database' + +const { ENV } = vi.hoisted(() => ({ + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { + SECRET: 'test-secret-for-encryption-key-32c', + TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003', + SECURE_COOKIES: false, + ADMIN_EMAIL: undefined as string | undefined, + ADMIN_PASSWORD: undefined as string | undefined, + ADMIN_PASSWORD_RESET: false, + GITHUB_CLIENT_ID: undefined as string | undefined, + GITHUB_CLIENT_SECRET: undefined as string | undefined, + GOOGLE_CLIENT_ID: undefined as string | undefined, + GOOGLE_CLIENT_SECRET: undefined as string | undefined, + DISCORD_CLIENT_ID: undefined as string | undefined, + DISCORD_CLIENT_SECRET: undefined as string | undefined, + PASSKEY_RP_ID: 'localhost', + PASSKEY_RP_NAME: 'OpenCode Manager', + PASSKEY_ORIGIN: 'http://localhost:5003', + }, + LOGGING: { DEBUG: false, LOG_LEVEL: 'info' }, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + ENV, + getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS + .split(',') + .map((origin: string) => origin.trim()) + .filter((origin: string) => origin.length > 0), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +const ORIGIN = 'http://localhost:5173' +const SIGN_UP_URL = `${ORIGIN}/api/auth/sign-up/email` +const REDEEM_URL = `${ORIGIN}/api/auth/device-pairing/redeem` +const UNROUTABLE_CREATE_URL = `${ORIGIN}/api/auth/device-pairing/create` +const DEVICES_URL = `${ORIGIN}/api/auth/device-pairing/devices` +const REVOKE_URL = `${ORIGIN}/api/auth/device-pairing/devices/revoke` +const PAIRED_DEVICE_KEYS = Object.keys(PairedDeviceSchema.shape).sort() + +type PairedDeviceBody = { + id: string + deviceName: string | null + userAgent: string | null + ipAddress: string | null + createdAt: string + updatedAt: string + isCurrent: boolean +} + +function cookieHeaders(response: Response): Headers { + const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]) + return new Headers({ cookie: cookies.join('; ') }) +} + +function redeemRequest(code: string, deviceName?: string, ipAddress = '198.51.100.7'): Request { + return new Request(REDEEM_URL, { + method: 'POST', + headers: { + origin: ORIGIN, + 'content-type': 'application/json', + 'user-agent': 'TestPhone/1.0', + [AUTH_SOCKET_IP_HEADER]: ipAddress, + }, + body: JSON.stringify(deviceName === undefined ? { code } : { code, deviceName }), + }) +} + +function deviceRequest(url: string, method: 'GET' | 'POST', headers?: Headers, body?: unknown): Request { + const requestHeaders: Record = { + origin: ORIGIN, + 'content-type': 'application/json', + [AUTH_SOCKET_IP_HEADER]: '198.51.100.31', + } + const cookie = headers?.get('cookie') + if (cookie) requestHeaders.cookie = cookie + + return new Request(url, { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +describe('devicePairing', () => { + let db: Database + let auth: AuthInstance + + beforeEach(() => { + db = createAuthDatabase() + auth = createAuth(db) + }) + + afterEach(() => { + db.close() + }) + + async function signUp(email = 'creator@example.com', name = 'Creator'): Promise { + const response = await auth.handler(new Request(SIGN_UP_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ email, password: 'test-password', name }), + })) + expect(response.status).toBe(200) + return cookieHeaders(response) + } + + async function listDevices(headers: Headers): Promise<{ status: number; devices: PairedDeviceBody[] }> { + const response = await auth.handler(deviceRequest(DEVICES_URL, 'GET', headers)) + const body = await response.json() as { devices?: PairedDeviceBody[] } + return { status: response.status, devices: body.devices ?? [] } + } + + async function pairPhone(creatorHeaders: Headers, deviceName: string): Promise { + const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders }) + const response = await auth.handler(redeemRequest(code, deviceName, '198.51.100.55')) + expect(response.status).toBe(200) + return cookieHeaders(response) + } + + it('mints a code stored only as a hash with the three-minute lifetime', async () => { + const headers = await signUp() + const mintedAt = Date.now() + + const { code, expiresAt } = await auth.api.createDevicePairingCode({ headers }) + + expect(code).toMatch(/^[A-Za-z0-9_-]{43}$/) + const lifetimeMs = new Date(expiresAt).getTime() - mintedAt + expect(lifetimeMs).toBeGreaterThan((DEVICE_PAIRING_CODE_TTL_SECONDS - 5) * 1000) + expect(lifetimeMs).toBeLessThanOrEqual((DEVICE_PAIRING_CODE_TTL_SECONDS + 5) * 1000) + + const rows = db.prepare('SELECT identifier, value, expiresAt FROM verification').all() as Array<{ + identifier: string + value: string + expiresAt: number + }> + expect(rows).toHaveLength(1) + expect(rows[0]!.identifier).toBe(`device-pairing:${createHash('sha256').update(code).digest('base64url')}`) + expect(JSON.stringify(rows)).not.toContain(code) + }) + + it('does not expose code minting over HTTP', async () => { + const headers = await signUp() + + const response = await auth.handler(new Request(UNROUTABLE_CREATE_URL, { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json', cookie: headers.get('cookie') ?? '' }, + body: '{}', + })) + + expect(response.status).toBe(404) + }) + + it('redeems a code into a new session for the same user', async () => { + const creatorHeaders = await signUp() + const creatorSession = await auth.api.getSession({ headers: creatorHeaders }) + expect(creatorSession).not.toBeNull() + + const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders }) + const response = await auth.handler(redeemRequest(code, 'Chris iPhone')) + + expect(response.status).toBe(200) + const body = await response.json() as { user: { id: string; name: string; email: string } } + expect(body.user.id).toBe(creatorSession!.user.id) + expect(body.user.email).toBe('creator@example.com') + expect(body.user).not.toHaveProperty('token') + + const cookies = response.headers.getSetCookie() + expect(cookies.length).toBeGreaterThan(0) + const phoneSession = await auth.api.getSession({ headers: cookieHeaders(response) }) + expect(phoneSession).not.toBeNull() + expect(phoneSession!.user.id).toBe(creatorSession!.user.id) + expect(phoneSession!.session.id).not.toBe(creatorSession!.session.id) + expect(phoneSession!.session.deviceName).toBe('Chris iPhone') + expect(phoneSession!.session.userAgent).toBe('TestPhone/1.0') + + const creatorAfter = await auth.api.getSession({ headers: creatorHeaders }) + expect(creatorAfter?.session.id).toBe(creatorSession!.session.id) + expect(db.prepare('SELECT COUNT(*) AS count FROM "session"').get()).toEqual({ count: 2 }) + }) + + it('consumes a code once', async () => { + const headers = await signUp() + const { code } = await auth.api.createDevicePairingCode({ headers }) + + const first = await auth.handler(redeemRequest(code, 'First Phone')) + expect(first.status).toBe(200) + + const second = await auth.handler(redeemRequest(code, 'Second Phone')) + expect(second.status).toBe(400) + const body = await second.json() as { code?: string; message?: string } + expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID) + expect(body.message).toBe(PAIRING_CODE_INVALID_MESSAGE) + }) + + it('rejects an expired code', async () => { + const headers = await signUp() + const { code } = await auth.api.createDevicePairingCode({ headers }) + db.prepare('UPDATE verification SET expiresAt = ?').run(Date.now() - 1000) + + const response = await auth.handler(redeemRequest(code, 'Late Phone')) + + expect(response.status).toBe(400) + const body = await response.json() as { code?: string } + expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID) + }) + + it('allows exactly one of two concurrent redeems', async () => { + const headers = await signUp() + const { code } = await auth.api.createDevicePairingCode({ headers }) + + const responses = await Promise.all([ + auth.handler(redeemRequest(code, 'Phone A', '198.51.100.20')), + auth.handler(redeemRequest(code, 'Phone B', '198.51.100.20')), + ]) + + expect(responses.map((response) => response.status).sort()).toEqual([200, 400]) + expect(db.prepare('SELECT COUNT(*) AS count FROM "session"').get()).toEqual({ count: 2 }) + }) + + it('creates a session without a device name when none is given', async () => { + const headers = await signUp() + const { code } = await auth.api.createDevicePairingCode({ headers }) + + const response = await auth.handler(redeemRequest(code)) + expect(response.status).toBe(200) + + const session = await auth.api.getSession({ headers: cookieHeaders(response) }) + expect(session).not.toBeNull() + expect(session!.session.deviceName ?? null).toBeNull() + }) + + it('lists active sessions as paired devices without session tokens', async () => { + const creatorHeaders = await signUp() + await pairPhone(creatorHeaders, 'Chris iPhone') + + const { status, devices } = await listDevices(creatorHeaders) + + expect(status).toBe(200) + expect(devices).toHaveLength(2) + for (const device of devices) { + expect(Object.keys(device).sort()).toEqual(PAIRED_DEVICE_KEYS) + } + + const updatedAt = devices.map((device) => device.updatedAt) + expect(updatedAt).toEqual([...updatedAt].sort().reverse()) + + const creatorSession = await auth.api.getSession({ headers: creatorHeaders }) + const current = devices.find((device) => device.id === creatorSession!.session.id) + expect(current?.isCurrent).toBe(true) + + const phone = devices.find((device) => device.id !== creatorSession!.session.id) + expect(phone).toMatchObject({ + deviceName: 'Chris iPhone', + userAgent: 'TestPhone/1.0', + ipAddress: '198.51.100.55', + isCurrent: false, + }) + + const tokens = db.prepare('SELECT token FROM "session"').all() as Array<{ token: string }> + expect(tokens).toHaveLength(2) + const serialized = JSON.stringify(devices) + for (const { token } of tokens) expect(serialized).not.toContain(token) + }) + + it('revokes one paired device without signing out the current one', async () => { + const creatorHeaders = await signUp() + const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone') + const creatorSession = await auth.api.getSession({ headers: creatorHeaders }) + + const { devices } = await listDevices(creatorHeaders) + const phone = devices.find((device) => device.id !== creatorSession!.session.id) + expect(phone).toBeDefined() + + const response = await auth.handler(deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phone!.id })) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: true }) + expect(await auth.api.getSession({ headers: phoneHeaders })).toBeNull() + const creatorAfter = await auth.api.getSession({ headers: creatorHeaders }) + expect(creatorAfter?.session.id).toBe(creatorSession!.session.id) + }) + + it('removes only the revoked device push subscriptions', async () => { + const creatorHeaders = await signUp() + const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone') + const creatorSession = await auth.api.getSession({ headers: creatorHeaders }) + const phoneSession = await auth.api.getSession({ headers: phoneHeaders }) + expect(phoneSession).not.toBeNull() + + const insertSubscription = db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, session_id, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + insertSubscription.run('default', 'https://push.example.com/phone', 'p', 'a', 'Chris iPhone', phoneSession!.session.id, 1, 1) + insertSubscription.run('default', 'https://push.example.com/desktop', 'p', 'a', 'Desktop', creatorSession!.session.id, 1, 1) + insertSubscription.run('default', 'https://push.example.com/legacy', 'p', 'a', 'Legacy', null, 1, 1) + + const response = await auth.handler( + deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phoneSession!.session.id }), + ) + + expect(response.status).toBe(200) + const endpoints = (db.prepare('SELECT endpoint FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string }>) + .map((row) => row.endpoint) + expect(endpoints).toEqual([ + 'https://push.example.com/desktop', + 'https://push.example.com/legacy', + ]) + }) + + it('refuses to revoke the current session', async () => { + const creatorHeaders = await signUp() + const creatorSession = await auth.api.getSession({ headers: creatorHeaders }) + + const response = await auth.handler( + deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: creatorSession!.session.id }), + ) + + expect(response.status).toBe(400) + const body = await response.json() as { code?: string } + expect(body.code).toBe(DEVICE_PAIRING_ERROR_CODES.CANNOT_REVOKE_CURRENT_SESSION) + expect(await auth.api.getSession({ headers: creatorHeaders })).not.toBeNull() + }) + + it('reports unknown and foreign session ids as missing', async () => { + const creatorHeaders = await signUp() + const otherHeaders = await signUp('other@example.com', 'Other') + const otherSession = await auth.api.getSession({ headers: otherHeaders }) + + const unknown = await auth.handler( + deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: 'unknown-session' }), + ) + expect(unknown.status).toBe(404) + expect((await unknown.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND) + + const foreign = await auth.handler( + deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: otherSession!.session.id }), + ) + expect(foreign.status).toBe(404) + expect((await foreign.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND) + + expect(await auth.api.getSession({ headers: otherHeaders })).not.toBeNull() + }) + + it('requires a session for the device endpoints', async () => { + const list = await auth.handler(deviceRequest(DEVICES_URL, 'GET')) + expect(list.status).toBe(401) + + const revoke = await auth.handler(deviceRequest(REVOKE_URL, 'POST', undefined, { id: 'anything' })) + expect(revoke.status).toBe(401) + }) + + it('lists only unexpired sessions as paired devices', async () => { + const creatorHeaders = await signUp() + const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone') + const phoneSession = await auth.api.getSession({ headers: phoneHeaders }) + expect(phoneSession).not.toBeNull() + + db.prepare('UPDATE "session" SET expiresAt = ? WHERE id = ?') + .run(new Date(Date.now() - 1000).toISOString(), phoneSession!.session.id) + + const { devices } = await listDevices(creatorHeaders) + expect(devices.map((device) => device.id)).not.toContain(phoneSession!.session.id) + + const revoke = await auth.handler( + deviceRequest(REVOKE_URL, 'POST', creatorHeaders, { id: phoneSession!.session.id }), + ) + expect(revoke.status).toBe(404) + expect((await revoke.json() as { code?: string }).code).toBe(DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND) + }) + + it('deletes only expired device pairing codes when minting', async () => { + const headers = await signUp() + const now = Date.now() + const insertVerification = db.prepare( + 'INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)', + ) + const expiredAt = new Date(now - 1000).toISOString() + insertVerification.run('expired-pairing', 'device-pairing:expired', 'user-1', expiredAt, expiredAt, expiredAt) + insertVerification.run( + 'active-pairing', + 'device-pairing:active', + 'user-1', + new Date(now + 60_000).toISOString(), + expiredAt, + expiredAt, + ) + insertVerification.run('expired-other', 'email-verification:expired', 'user-1', expiredAt, expiredAt, expiredAt) + + const { code } = await auth.api.createDevicePairingCode({ headers }) + + const identifiers = (db.prepare('SELECT identifier FROM verification').all() as Array<{ identifier: string }>) + .map((row) => row.identifier) + .sort() + expect(identifiers).toEqual([ + 'device-pairing:active', + `device-pairing:${createHash('sha256').update(code).digest('base64url')}`, + 'email-verification:expired', + ].sort()) + }) + + it('does not override rate limiting for the redeem endpoint', () => { + expect(devicePairing()).not.toHaveProperty('rateLimit') + }) +}) diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts index 70af8bf33..65dd20c7e 100644 --- a/backend/test/auth/index.test.ts +++ b/backend/test/auth/index.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { createAuth } from '../../src/auth' -import { DatabaseSync } from 'node:sqlite' -import type { Database } from 'bun:sqlite' -import { migrate } from '../../src/db/migration-runner' -import { allMigrations } from '../../src/db/migrations' import { syncAdminFromEnv } from '../../src/routes/auth' +import { createAuthDatabase } from '../helpers/auth-database' const { ENV } = vi.hoisted(() => ({ ENV: { @@ -56,15 +53,6 @@ function resetEnv(): void { ENV.AUTH.DISCORD_CLIENT_SECRET = undefined } -function createAuthDatabase(): Database { - const db = new DatabaseSync(':memory:') - const compatible = Object.assign(db, { - run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params), - }) as unknown as Database - migrate(compatible, allMigrations) - return compatible -} - describe('createAuth', () => { beforeEach(() => { resetEnv() diff --git a/backend/test/auth/session-delete-hooks.test.ts b/backend/test/auth/session-delete-hooks.test.ts new file mode 100644 index 000000000..687dfbb68 --- /dev/null +++ b/backend/test/auth/session-delete-hooks.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Database } from 'bun:sqlite' +import { createAuth, type AuthInstance } from '../../src/auth' +import { closeSessionConnections, registerSessionConnection } from '../../src/services/session-connections' +import { createAuthDatabase } from '../helpers/auth-database' + +const { ENV } = vi.hoisted(() => ({ + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { + SECRET: 'test-secret-for-encryption-key-32c', + TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003', + SECURE_COOKIES: false, + ADMIN_EMAIL: undefined as string | undefined, + ADMIN_PASSWORD: undefined as string | undefined, + ADMIN_PASSWORD_RESET: false, + GITHUB_CLIENT_ID: undefined as string | undefined, + GITHUB_CLIENT_SECRET: undefined as string | undefined, + GOOGLE_CLIENT_ID: undefined as string | undefined, + GOOGLE_CLIENT_SECRET: undefined as string | undefined, + DISCORD_CLIENT_ID: undefined as string | undefined, + DISCORD_CLIENT_SECRET: undefined as string | undefined, + PASSKEY_RP_ID: 'localhost', + PASSKEY_RP_NAME: 'OpenCode Manager', + PASSKEY_ORIGIN: 'http://localhost:5003', + }, + LOGGING: { DEBUG: false, LOG_LEVEL: 'info' }, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + ENV, + getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS + .split(',') + .map((origin: string) => origin.trim()) + .filter((origin: string) => origin.length > 0), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +const ORIGIN = 'http://localhost:5173' +const SIGN_UP_URL = `${ORIGIN}/api/auth/sign-up/email` +const SIGN_OUT_URL = `${ORIGIN}/api/auth/sign-out` +const REDEEM_URL = `${ORIGIN}/api/auth/device-pairing/redeem` +const DEVICES_URL = `${ORIGIN}/api/auth/device-pairing/devices` +const REVOKE_URL = `${ORIGIN}/api/auth/device-pairing/devices/revoke` + +function cookieHeaders(response: Response): Headers { + const cookies = response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]) + return new Headers({ cookie: cookies.join('; ') }) +} + +function cookieHeaderFrom(headers: Headers, url: string, method: 'GET' | 'POST', body?: unknown): Request { + const requestHeaders: Record = { + origin: ORIGIN, + 'content-type': 'application/json', + } + const cookie = headers.get('cookie') + if (cookie) requestHeaders.cookie = cookie + + return new Request(url, { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +describe('session delete hooks', () => { + let db: Database + let auth: AuthInstance + + beforeEach(() => { + db = createAuthDatabase() + auth = createAuth(db) + }) + + afterEach(() => { + db.close() + }) + + async function signUp(email = 'creator@example.com'): Promise { + const response = await auth.handler(new Request(SIGN_UP_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ email, password: 'test-password', name: 'Creator' }), + })) + expect(response.status).toBe(200) + return cookieHeaders(response) + } + + async function pairPhone(creatorHeaders: Headers, deviceName: string): Promise { + const { code } = await auth.api.createDevicePairingCode({ headers: creatorHeaders }) + const response = await auth.handler(new Request(REDEEM_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN, 'user-agent': 'TestPhone/1.0' }, + body: JSON.stringify({ code, deviceName }), + })) + expect(response.status).toBe(200) + return cookieHeaders(response) + } + + async function sessionId(headers: Headers): Promise { + const session = await auth.api.getSession({ headers }) + expect(session).not.toBeNull() + return session!.session.id + } + + it('closes the connections of a device revoked through the pairing plugin', async () => { + const creatorHeaders = await signUp() + const phoneHeaders = await pairPhone(creatorHeaders, 'Chris iPhone') + const phoneSessionId = await sessionId(phoneHeaders) + const creatorSessionId = await sessionId(creatorHeaders) + + const phoneClose = vi.fn() + const creatorClose = vi.fn() + registerSessionConnection(phoneSessionId, phoneClose) + registerSessionConnection(creatorSessionId, creatorClose) + + const devicesResponse = await auth.handler(cookieHeaderFrom(creatorHeaders, DEVICES_URL, 'GET')) + const { devices } = await devicesResponse.json() as { devices: Array<{ id: string }> } + expect(devices.map((device) => device.id)).toContain(phoneSessionId) + + const revokeResponse = await auth.handler( + cookieHeaderFrom(creatorHeaders, REVOKE_URL, 'POST', { id: phoneSessionId }), + ) + + expect(revokeResponse.status).toBe(200) + await vi.waitFor(() => expect(phoneClose).toHaveBeenCalledTimes(1)) + expect(creatorClose).not.toHaveBeenCalled() + }) + + it('closes the connections of the session that signs out', async () => { + const headers = await signUp() + const sessionId_ = await sessionId(headers) + const close = vi.fn() + registerSessionConnection(sessionId_, close) + + const response = await auth.handler(cookieHeaderFrom(headers, SIGN_OUT_URL, 'POST')) + + expect(response.status).toBe(200) + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)) + }) + + it('closes the connections of a session revoked through the auth api', async () => { + const headers = await signUp() + const session = await auth.api.getSession({ headers }) + expect(session).not.toBeNull() + const close = vi.fn() + registerSessionConnection(session!.session.id, close) + + const response = await auth.api.revokeSession({ + headers, + body: { token: session!.session.token }, + }) + + expect(response).toEqual({ status: true }) + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)) + }) + + it('does not leave closed sessions registered', async () => { + const headers = await signUp() + const sessionId_ = await sessionId(headers) + const close = vi.fn() + registerSessionConnection(sessionId_, close) + + await auth.handler(cookieHeaderFrom(headers, SIGN_OUT_URL, 'POST')) + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)) + + closeSessionConnections(sessionId_) + expect(close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/backend/test/db/push-subscription-session-id-migration.test.ts b/backend/test/db/push-subscription-session-id-migration.test.ts new file mode 100644 index 000000000..8d3392aeb --- /dev/null +++ b/backend/test/db/push-subscription-session-id-migration.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' + +const MIGRATION_ID = '202610231200-push-subscription-session-id' + +const LEGACY_TABLE = ` + CREATE TABLE push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + device_name TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) +` + +function columnNames(db: Database): string[] { + return (db.prepare('PRAGMA table_info(push_subscriptions)').all() as Array<{ name: string }>) + .map((column) => column.name) +} + +function indexNames(db: Database): string[] { + return (db.prepare('PRAGMA index_list(push_subscriptions)').all() as Array<{ name: string }>) + .map((index) => index.name) +} + +describe('push subscription session id migration', () => { + it('creates the push subscriptions table with its session id column', () => { + const db = new Database(':memory:') + migrate(db, allMigrations) + + expect(columnNames(db)).toContain('session_id') + expect(indexNames(db)).toContain('idx_push_sub_session') + expect(indexNames(db)).toContain('idx_push_sub_endpoint') + db.close() + }) + + it('adds the session id column to an existing table without losing subscriptions', () => { + const db = new Database(':memory:') + db.run(LEGACY_TABLE) + db.prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run('user-1', 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Old Phone', 1, 1) + + migrate(db, allMigrations) + + expect(columnNames(db)).toContain('session_id') + expect(indexNames(db)).toContain('idx_push_sub_session') + expect(db.prepare('SELECT endpoint, session_id FROM push_subscriptions').all()).toEqual([ + { endpoint: 'https://push.example.com/legacy', session_id: null }, + ]) + expect(db.prepare('SELECT COUNT(*) AS count FROM applied_migrations WHERE id = ?').get(MIGRATION_ID)) + .toEqual({ count: 1 }) + db.close() + }) + + it('can run again on a database that always had the column', () => { + const db = new Database(':memory:') + migrate(db, allMigrations) + db.prepare('DELETE FROM applied_migrations WHERE id = ?').run(MIGRATION_ID) + + migrate(db, allMigrations) + + expect(columnNames(db).filter((name) => name === 'session_id')).toHaveLength(1) + expect(indexNames(db).filter((name) => name === 'idx_push_sub_session')).toHaveLength(1) + db.close() + }) +}) diff --git a/backend/test/helpers/auth-database.ts b/backend/test/helpers/auth-database.ts new file mode 100644 index 000000000..f68e3fc60 --- /dev/null +++ b/backend/test/helpers/auth-database.ts @@ -0,0 +1,13 @@ +import { DatabaseSync } from 'node:sqlite' +import type { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' + +export function createAuthDatabase(): Database { + const db = new DatabaseSync(':memory:') + const compatible = Object.assign(db, { + run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params), + }) as unknown as Database + migrate(compatible, allMigrations) + return compatible +} diff --git a/backend/test/helpers/log-assertions.ts b/backend/test/helpers/log-assertions.ts new file mode 100644 index 000000000..d478ae6e3 --- /dev/null +++ b/backend/test/helpers/log-assertions.ts @@ -0,0 +1,24 @@ +import { expect, type Mock } from 'vitest' + +export interface LoggerMocks { + info: Mock + warn: Mock + error: Mock + debug: Mock +} + +export function loggedArguments(loggerMocks: LoggerMocks): unknown[][] { + return [...loggerMocks.info.mock.calls, ...loggerMocks.warn.mock.calls, ...loggerMocks.error.mock.calls] +} + +export function expectNoSecretsLogged(loggerMocks: LoggerMocks, secrets: readonly string[]): void { + const calls = loggedArguments(loggerMocks) + expect(calls.length).toBeGreaterThan(0) + + for (const args of calls) { + const serialized = JSON.stringify(args) + for (const value of secrets) { + expect(serialized).not.toContain(value) + } + } +} diff --git a/backend/test/helpers/remote-access-headers.ts b/backend/test/helpers/remote-access-headers.ts new file mode 100644 index 000000000..86bdf1b34 --- /dev/null +++ b/backend/test/helpers/remote-access-headers.ts @@ -0,0 +1,11 @@ +import type { RemoteAccessHeader } from '@opencode-manager/shared/schemas' + +export const CLOUDFLARE_CLIENT_ID = 'client-id-9f4c2b7e1a5d' +export const CLOUDFLARE_CLIENT_SECRET = 'client-secret-4d8e2f7b1c90' + +export const CLOUDFLARE_HEADERS: RemoteAccessHeader[] = [ + { name: 'CF-Access-Client-Id', value: CLOUDFLARE_CLIENT_ID }, + { name: 'CF-Access-Client-Secret', value: CLOUDFLARE_CLIENT_SECRET }, +] + +export const CLOUDFLARE_SECRET_VALUES = [CLOUDFLARE_CLIENT_ID, CLOUDFLARE_CLIENT_SECRET] diff --git a/backend/test/helpers/websocket-server.ts b/backend/test/helpers/websocket-server.ts index 73122517c..ec441e64f 100644 --- a/backend/test/helpers/websocket-server.ts +++ b/backend/test/helpers/websocket-server.ts @@ -6,7 +6,7 @@ import { serve } from '@hono/node-server' import { createNodeWebSocket } from '@hono/node-ws' export interface StartWebSocketServerOptions { - app: Hono + app: Hono register?: (app: Hono, upgradeWebSocket: UpgradeWebSocket) => void injectWebSocket?: (server: Server) => void } diff --git a/backend/test/routes/device-pairing.test.ts b/backend/test/routes/device-pairing.test.ts new file mode 100644 index 000000000..dc40b9f01 --- /dev/null +++ b/backend/test/routes/device-pairing.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { + DevicePairingLinksResponseSchema, + buildManagerBrowserPairingLink, + parseManagerAppLink, + type DevicePairingLinksResponse, +} from '@opencode-manager/shared/schemas' + +const loggerMocks = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ logger: loggerMocks })) + +import { createTestDb } from '../helpers/assistant-workspace' +import { expectNoSecretsLogged } from '../helpers/log-assertions' +import { CLOUDFLARE_HEADERS, CLOUDFLARE_SECRET_VALUES } from '../helpers/remote-access-headers' +import { writeEncryptedSecret } from '../../src/db/secrets' +import { + RemoteAccessHeadersService, + REMOTE_ACCESS_HEADERS_SECRET_KEY, +} from '../../src/services/remote-access-headers' +import { createDevicePairingRoutes } from '../../src/routes/device-pairing' + +const CODE = 'pairing-code-0123456789abcdef' +const EXPIRES_AT = new Date('2026-10-22T12:00:00.000Z') + +const TRUSTED_ORIGINS = [ + 'https://m.example.com', + 'http://192.168.1.20:5003', + 'http://203.0.113.5:5003', + 'http://localhost:5173', +] + +const MISSING_ORIGIN_ERROR = + 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.' +const untrustedOriginError = (origin: string) => + `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.` +const LINKS_FAILED_ERROR = 'Failed to create device pairing links' + +const SENSITIVE_VALUES = [CODE, ...CLOUDFLARE_SECRET_VALUES] + +const COOKIE_HEADER = 'session=test-session' + +const createMintPairingCode = () => + vi.fn<(headers: Headers) => Promise<{ code: string; expiresAt: Date }>>(async () => ({ + code: CODE, + expiresAt: EXPIRES_AT, + })) + +async function readLinksBody(response: Response): Promise { + return DevicePairingLinksResponseSchema.parse(await response.json()) +} + +describe('device pairing link routes', () => { + let db: Database + let remoteAccessHeaders: RemoteAccessHeadersService + let mintPairingCode: ReturnType + let allowPrivateNetwork: boolean + let development: boolean + let app: Hono + + beforeEach(() => { + allowPrivateNetwork = false + development = false + db = createTestDb() + remoteAccessHeaders = new RemoteAccessHeadersService(db) + remoteAccessHeaders.replace({ headers: CLOUDFLARE_HEADERS }) + mintPairingCode = createMintPairingCode() + app = new Hono() + app.route( + '/device-pairing', + createDevicePairingRoutes({ + mintPairingCode, + remoteAccessHeaders, + readAllowPrivateNetworkHttp: () => allowPrivateNetwork, + resolveTrustedOrigins: () => TRUSTED_ORIGINS, + isDevelopment: () => development, + }), + ) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + const postLinks = (origin?: string, body?: unknown) => { + const headers: Record = { + 'content-type': 'application/json', + cookie: COOKIE_HEADER, + } + if (origin) headers.origin = origin + + return app.request('/device-pairing/links', { + method: 'POST', + headers, + body: JSON.stringify(body ?? {}), + }) + } + + it('rejects a request without an origin', async () => { + const res = await postLinks() + + expect(res.status).toBe(400) + expect(res.headers.get('Cache-Control')).toBe('no-store') + await expect(res.json()).resolves.toEqual({ error: MISSING_ORIGIN_ERROR }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('rejects an untrusted origin', async () => { + const res = await postLinks('https://evil.example') + + expect(res.status).toBe(400) + expect(res.headers.get('Cache-Control')).toBe('no-store') + const body = await res.json() as { error: string } + expect(body.error).toBe(untrustedOriginError('https://evil.example')) + expect(body.error).toContain('AUTH_TRUSTED_ORIGINS') + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('rejects the null origin', async () => { + const res = await postLinks('null') + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: untrustedOriginError('null') }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('builds links for a trusted https origin', async () => { + const res = await postLinks('https://m.example.com') + + expect(res.status).toBe(200) + expect(res.headers.get('Cache-Control')).toBe('no-store') + const body = await readLinksBody(res) + + expect(parseManagerAppLink(body.appLink ?? '')).toEqual({ + kind: 'manager', + name: 'm.example.com', + baseUrl: 'https://m.example.com', + code: CODE, + headers: CLOUDFLARE_HEADERS, + }) + expect(body.browserLink).toBe(buildManagerBrowserPairingLink('https://m.example.com', CODE)) + expect(body.headerNames).toEqual(['CF-Access-Client-Id', 'CF-Access-Client-Secret']) + expect(body.warnings).toEqual([]) + expect(body.appLinkBlockedReason).toBeNull() + expect(body.expiresAt).toBe(EXPIRES_AT.toISOString()) + + expect(mintPairingCode).toHaveBeenCalledTimes(1) + const forwardedHeaders = mintPairingCode.mock.calls[0]?.[0] + expect(forwardedHeaders).toBeInstanceOf(Headers) + expect(forwardedHeaders?.get('cookie')).toBe(COOKIE_HEADER) + }) + + it('uses the supplied device name in the app link', async () => { + const res = await postLinks('https://m.example.com', { name: 'Office' }) + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(parseManagerAppLink(body.appLink ?? '')?.name).toBe('Office') + }) + + it('blocks a private network http origin when the toggle is off', async () => { + const res = await postLinks('http://192.168.1.20:5003') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(body.appLink).toBeNull() + expect(body.appLinkBlockedReason).toBe('insecure-origin') + expect(body.warnings).toEqual(['plain-http']) + expect(body.browserLink).toBe(buildManagerBrowserPairingLink('http://192.168.1.20:5003', CODE)) + }) + + it('allows a private network http origin when the toggle is on', async () => { + allowPrivateNetwork = true + + const res = await postLinks('http://192.168.1.20:5003') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://192.168.1.20:5003') + expect(body.warnings).toEqual(['plain-http']) + }) + + it('blocks a public http origin even when the toggle is on', async () => { + allowPrivateNetwork = true + + const res = await postLinks('http://203.0.113.5:5003') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(body.appLink).toBeNull() + expect(body.appLinkBlockedReason).toBe('insecure-origin') + }) + + it('blocks a loopback origin outside development', async () => { + allowPrivateNetwork = true + development = false + + const res = await postLinks('http://localhost:5173') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(body.appLink).toBeNull() + expect(body.appLinkBlockedReason).toBe('loopback') + expect(body.warnings).toEqual(['plain-http', 'loopback']) + }) + + it('allows a loopback origin in development', async () => { + allowPrivateNetwork = true + development = true + + const res = await postLinks('http://localhost:5173') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://localhost:5173') + }) + + it('reports unreadable stored headers', async () => { + writeEncryptedSecret( + db, + REMOTE_ACCESS_HEADERS_SECRET_KEY, + JSON.stringify([{ name: 'Cookie', value: 'stolen' }]), + ) + + const res = await postLinks('https://m.example.com') + + expect(res.status).toBe(200) + const body = await readLinksBody(res) + + expect(body.headerNames).toEqual([]) + expect(body.warnings).toContain('headers-unreadable') + expect(parseManagerAppLink(body.appLink ?? '')?.headers).toEqual([]) + }) + + it('fails without logging pairing secrets', async () => { + await postLinks('https://m.example.com') + mintPairingCode.mockRejectedValueOnce(new Error('mint failed')) + + const res = await postLinks('https://m.example.com') + + expect(res.status).toBe(500) + expect(res.headers.get('Cache-Control')).toBe('no-store') + await expect(res.json()).resolves.toEqual({ error: LINKS_FAILED_ERROR }) + + expectNoSecretsLogged(loggerMocks, SENSITIVE_VALUES) + }) +}) diff --git a/backend/test/routes/notifications.test.ts b/backend/test/routes/notifications.test.ts index 69b0ed653..2d0205a3e 100644 --- a/backend/test/routes/notifications.test.ts +++ b/backend/test/routes/notifications.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' import { createNotificationRoutes } from '../../src/routes/notifications' import type { NotificationService } from '../../src/services/notification' +import type { Session } from '../../src/auth' interface NotificationServiceStub { getVapidPublicKey: ReturnType @@ -82,6 +84,7 @@ describe('Notification Routes', () => { 'p256dh-key', 'auth-key', 'Phone', + undefined, ) }) @@ -106,6 +109,38 @@ describe('Notification Routes', () => { 'p256dh-key', 'auth-key', undefined, + undefined, + ) + }) + + it('saves the session of the device that subscribed', async () => { + service.saveSubscription.mockReturnValue({ id: 3 }) + + const sessionApp = new Hono<{ Variables: { session?: Session['session'] } }>() + sessionApp.use('*', async (c, next) => { + c.set('session', { id: 'ses_phone' } as Session['session']) + await next() + }) + sessionApp.route('/', createNotificationRoutes(service as unknown as NotificationService)) + + const res = await sessionApp.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: 'https://push.example.com/abc', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + deviceName: 'Phone', + }), + })) + + expect(res.status).toBe(200) + expect(service.saveSubscription).toHaveBeenCalledWith( + 'default', + 'https://push.example.com/abc', + 'p256dh-key', + 'auth-key', + 'Phone', + 'ses_phone', ) }) diff --git a/backend/test/routes/opencode-server-terminal-socket.test.ts b/backend/test/routes/opencode-server-terminal-socket.test.ts index bc386095b..f2acfb7b9 100644 --- a/backend/test/routes/opencode-server-terminal-socket.test.ts +++ b/backend/test/routes/opencode-server-terminal-socket.test.ts @@ -10,6 +10,12 @@ import { TerminalService } from '../../src/services/terminal' import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import type { Session } from '../../src/auth' +import { + closeSessionConnections, + SESSION_CONNECTION_CLOSE_CODE, + SESSION_CONNECTION_CLOSE_REASON, +} from '../../src/services/session-connections' import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' @@ -38,7 +44,9 @@ const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as Cred let upstream: WebSocketTestServer let manager: WebSocketTestServer +let sessionServer: WebSocketTestServer let managerPort = 0 +let sessionPort = 0 let capturedUrl: URL | undefined const upstreamApp = new Hono() @@ -75,6 +83,25 @@ managerApp.route( createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, managerWs.upgradeWebSocket, [TRUSTED_ORIGIN]), ) +const SESSION_ID = 'session-server-terminal' +const sessionApp = new Hono<{ Variables: { session: Session['session'] } }>() +const sessionWs = createNodeWebSocket({ app: sessionApp }) +sessionApp.use('/*', async (c, next) => { + c.set('session', { + id: SESSION_ID, + userId: 'user-1', + token: 'token-server-terminal', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }) + await next() +}) +sessionApp.route( + '/', + createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, sessionWs.upgradeWebSocket, [TRUSTED_ORIGIN]), +) + function clientUrl(ptyID: string, directory = DIRECTORY, cursor?: number): string { const url = new URL(`ws://127.0.0.1:${managerPort}/${SERVER_ID}/terminals/${ptyID}/connect`) url.searchParams.set('directory', directory) @@ -105,13 +132,17 @@ beforeAll(async () => { upstream = await startWebSocketServer({ app: upstreamApp, injectWebSocket: upstreamWs.injectWebSocket }) manager = await startWebSocketServer({ app: managerApp, injectWebSocket: managerWs.injectWebSocket }) managerPort = manager.port + sessionServer = await startWebSocketServer({ app: sessionApp, injectWebSocket: sessionWs.injectWebSocket }) + sessionPort = sessionServer.port }) afterAll(async () => { upstreamWs.wss.close() managerWs.wss.close() + sessionWs.wss.close() await upstream.close() await manager.close() + await sessionServer.close() }) describe('OpenCode Server Terminal Socket Route', () => { @@ -177,3 +208,28 @@ describe('OpenCode Server Terminal Socket Route', () => { expect(outcome).toEqual({ upgraded: false, status: 400 }) }) }) + +describe('OpenCode Server Terminal Socket session binding', () => { + it('closes the socket when its session is deleted', async () => { + const socket = new WebSocket(`ws://127.0.0.1:${sessionPort}/${SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`) + socket.binaryType = 'arraybuffer' + const messages: Array = [] + socket.addEventListener('message', (event) => { + messages.push(event.data as string | ArrayBuffer) + }) + + await openEvent(socket) + socket.send('echo session') + await waitFor(() => messages.includes('echo session')) + + const closeEvent = new Promise((resolve) => { + socket.addEventListener('close', (event) => resolve(event), { once: true }) + }) + + closeSessionConnections(SESSION_ID) + + const event = await closeEvent + expect(event.code).toBe(SESSION_CONNECTION_CLOSE_CODE) + expect(event.reason).toBe(SESSION_CONNECTION_CLOSE_REASON) + }) +}) diff --git a/backend/test/routes/remote-access-headers.test.ts b/backend/test/routes/remote-access-headers.test.ts new file mode 100644 index 000000000..87c4af332 --- /dev/null +++ b/backend/test/routes/remote-access-headers.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { ENV } from '@opencode-manager/shared/config/env' + +const loggerMocks = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ logger: loggerMocks })) + +import { createTestDb } from '../helpers/assistant-workspace' +import { expectNoSecretsLogged } from '../helpers/log-assertions' +import { + CLOUDFLARE_CLIENT_ID, + CLOUDFLARE_CLIENT_SECRET, + CLOUDFLARE_HEADERS, + CLOUDFLARE_SECRET_VALUES, +} from '../helpers/remote-access-headers' +import { decryptSecret } from '../../src/utils/crypto' +import { RemoteAccessHeadersService, REMOTE_ACCESS_HEADERS_SECRET_KEY } from '../../src/services/remote-access-headers' +import { createRemoteAccessHeaderRoutes } from '../../src/routes/remote-access-headers' + +const REPLACEMENT_SECRET_VALUE = 'replacement-secret-77aa13c5' + +const SECRET_VALUES = [...CLOUDFLARE_SECRET_VALUES, REPLACEMENT_SECRET_VALUE] + +const mutableAuth = ENV.AUTH as unknown as { SECRET: string } +const originalSecret = ENV.AUTH.SECRET + +function buildHeaders(count: number) { + return Array.from({ length: count }, (_, index) => ({ name: `X-Remote-Header-${index}`, value: `value-${index}` })) +} + +describe('remote access header routes', () => { + let db: Database + let service: RemoteAccessHeadersService + let app: Hono + + beforeEach(() => { + db = createTestDb() + service = new RemoteAccessHeadersService(db) + app = new Hono() + app.route('/remote-access-headers', createRemoteAccessHeaderRoutes(service)) + }) + + afterEach(() => { + mutableAuth.SECRET = originalSecret + vi.clearAllMocks() + }) + + const readSummary = () => app.request('/remote-access-headers') + + const putHeaders = (headers: Array<{ name: string; value?: string }>) => + app.request('/remote-access-headers', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ headers }), + }) + + it('reads an empty configuration', async () => { + const res = await readSummary() + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false }) + }) + + it('stores headers without exposing their values', async () => { + const res = await putHeaders(CLOUDFLARE_HEADERS) + + expect(res.status).toBe(200) + const body = await res.text() + expect(JSON.parse(body)).toEqual({ + headers: [ + { name: 'CF-Access-Client-Id', hasValue: true }, + { name: 'CF-Access-Client-Secret', hasValue: true }, + ], + unreadable: false, + }) + expect(body).not.toContain(CLOUDFLARE_CLIENT_ID) + expect(body).not.toContain(CLOUDFLARE_CLIENT_SECRET) + + const readBody = await (await readSummary()).text() + expect(readBody).not.toContain(CLOUDFLARE_CLIENT_ID) + expect(readBody).not.toContain(CLOUDFLARE_CLIENT_SECRET) + }) + + it('stores header values encrypted at rest', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + + const row = db + .prepare('SELECT value FROM app_secrets WHERE key = ?') + .get(REMOTE_ACCESS_HEADERS_SECRET_KEY) as { value: string } | undefined + + expect(row).toBeDefined() + const encrypted = row?.value ?? '' + expect(encrypted).not.toContain(CLOUDFLARE_CLIENT_ID) + expect(encrypted).not.toContain(CLOUDFLARE_CLIENT_SECRET) + expect(JSON.parse(decryptSecret(encrypted))).toEqual(CLOUDFLARE_HEADERS) + }) + + it('keeps a stored value when the replacement value is blank', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + + const res = await putHeaders([{ name: 'CF-Access-Client-Secret', value: '' }]) + + expect(res.status).toBe(200) + expect(service.read()).toEqual({ + headers: [{ name: 'CF-Access-Client-Secret', value: CLOUDFLARE_CLIENT_SECRET }], + unreadable: false, + }) + }) + + it('uses a supplied value over the stored one', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + + const res = await putHeaders([{ name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE }]) + + expect(res.status).toBe(200) + expect(service.read().headers).toEqual([ + { name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE }, + ]) + }) + + it('rejects a blank value for a header that has no stored value', async () => { + const res = await putHeaders([{ name: 'CF-Access-Client-Id', value: '' }]) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'A value is required for CF-Access-Client-Id' }) + }) + + it('rejects a reserved header name', async () => { + const res = await putHeaders([{ name: 'Cookie', value: 'session=1' }]) + + expect(res.status).toBe(400) + expect(service.read().headers).toEqual([]) + }) + + it('rejects more than eight headers', async () => { + const res = await putHeaders(buildHeaders(9)) + + expect(res.status).toBe(400) + expect(service.read().headers).toEqual([]) + }) + + it('rejects duplicate header names after merging', async () => { + const res = await putHeaders([ + { name: 'CF-Access-Client-Id', value: 'first' }, + { name: 'cf-access-client-id', value: 'second' }, + ]) + + expect(res.status).toBe(400) + expect(service.read().headers).toEqual([]) + }) + + it('rejects a value with a line break after merging', async () => { + const res = await putHeaders([{ name: 'X-Remote-Header', value: 'line\r\nbreak' }]) + + expect(res.status).toBe(400) + expect(service.read().headers).toEqual([]) + }) + + it('clears stored headers with an empty replacement', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + + const res = await putHeaders([]) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false }) + await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: false }) + }) + + it('clears stored headers on delete', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + + const res = await app.request('/remote-access-headers', { method: 'DELETE' }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ headers: [], unreadable: false }) + await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: false }) + }) + + it('reports headers that cannot be decrypted as unreadable and unset', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + mutableAuth.SECRET = 'a-different-auth-secret' + + await expect((await readSummary()).json()).resolves.toEqual({ headers: [], unreadable: true }) + expect(service.read()).toEqual({ headers: [], unreadable: true }) + }) + + it('recovers the stored headers when the original secret returns', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + mutableAuth.SECRET = 'a-different-auth-secret' + await readSummary() + mutableAuth.SECRET = originalSecret + + expect(service.read().headers).toEqual(CLOUDFLARE_HEADERS) + }) + + describe('logging', () => { + afterEach(() => { + expectNoSecretsLogged(loggerMocks, SECRET_VALUES) + }) + + it('logs a write without values', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + await putHeaders([{ name: 'CF-Access-Client-Secret', value: REPLACEMENT_SECRET_VALUE }]) + await app.request('/remote-access-headers', { method: 'DELETE' }) + }) + + it('logs an unreadable store without values', async () => { + await putHeaders(CLOUDFLARE_HEADERS) + mutableAuth.SECRET = 'a-different-auth-secret' + await readSummary() + }) + }) +}) diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index efebec97b..35ad2068f 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -9,6 +9,13 @@ import { TerminalService } from '../../src/services/terminal' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' +import type { Session } from '../../src/auth' +import { + closeSessionConnections, + registerSessionConnection, + SESSION_CONNECTION_CLOSE_CODE, + SESSION_CONNECTION_CLOSE_REASON, +} from '../../src/services/session-connections' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' import { createStubRepoEnvService } from '../helpers/stub-repo-env-service' @@ -51,6 +58,7 @@ const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as Cred let upstream: WebSocketTestServer let manager: WebSocketTestServer let authenticated: WebSocketTestServer +let sessionServer: WebSocketTestServer let managerPort = 0 let upstreamBase = '' let capturedUrl: URL | undefined @@ -123,12 +131,45 @@ authenticatedApp.route( let authenticatedPort = 0 +const SESSION_ID = 'session-terminal' +const sessionApp = new Hono<{ Variables: { session: Session['session'] } }>() +const sessionWs = createNodeWebSocket({ app: sessionApp }) +sessionApp.use('/*', async (c, next) => { + c.set('session', { + id: SESSION_ID, + userId: 'user-1', + token: 'token-terminal', + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }) + await next() +}) +sessionApp.route( + '/', + createRepoTerminalSocketRoutes( + database, + gitAuthService, + openCodeClient, + terminalService, + sessionWs.upgradeWebSocket, + ), +) + +let sessionPort = 0 + function clientUrl(ptyID: string, directory = '/tmp/repo'): string { const url = new URL(`ws://127.0.0.1:${managerPort}/1/terminals/${ptyID}/connect`) url.searchParams.set('directory', directory) return url.toString() } +function sessionClientUrl(ptyID: string, directory = '/tmp/repo'): string { + const url = new URL(`ws://127.0.0.1:${sessionPort}/1/terminals/${ptyID}/connect`) + url.searchParams.set('directory', directory) + return url.toString() +} + function openEvent(socket: WebSocket): Promise { return new Promise((resolve, reject) => { if (socket.readyState === WebSocket.OPEN) { @@ -166,6 +207,9 @@ beforeAll(async () => { authenticated = await startWebSocketServer({ app: authenticatedApp, injectWebSocket: authenticatedWs.injectWebSocket }) authenticatedPort = authenticated.port + sessionServer = await startWebSocketServer({ app: sessionApp, injectWebSocket: sessionWs.injectWebSocket }) + sessionPort = sessionServer.port + vi.mocked(resolveRepoOrAssistant).mockImplementation((_db, id) => (id === 1 ? readyRepo : null)) vi.mocked(resolveRepoWorkingDirectory).mockImplementation(async (_repo, directory) => { if (directory === undefined || directory === '/tmp/repo') return '/tmp/repo' @@ -178,9 +222,11 @@ afterAll(async () => { upstreamWs.wss.close() managerWs.wss.close() authenticatedWs.wss.close() + sessionWs.wss.close() await upstream.close() await manager.close() await authenticated.close() + await sessionServer.close() }) describe('Repo Terminal Socket Route', () => { @@ -232,6 +278,59 @@ describe('Repo Terminal Socket Route', () => { }) }) +describe('Repo Terminal Socket session binding', () => { + it('closes the socket when its session is deleted', async () => { + const socket = new WebSocket(sessionClientUrl('pty-1')) + socket.binaryType = 'arraybuffer' + const messages: Array = [] + socket.addEventListener('message', (event) => { + messages.push(event.data as string | ArrayBuffer) + }) + + await openEvent(socket) + await waitFor(() => messages.length === 1) + + const closeEvent = new Promise((resolve) => { + socket.addEventListener('close', (event) => resolve(event), { once: true }) + }) + const otherSessionClose = vi.fn() + registerSessionConnection('session-terminal-other', otherSessionClose) + + closeSessionConnections(SESSION_ID) + + const event = await closeEvent + expect(event.code).toBe(SESSION_CONNECTION_CLOSE_CODE) + expect(event.reason).toBe(SESSION_CONNECTION_CLOSE_REASON) + expect(otherSessionClose).not.toHaveBeenCalled() + closeSessionConnections('session-terminal-other') + }) + + it('unregisters the socket so a repeated session close does not fire again', async () => { + const socket = new WebSocket(sessionClientUrl('pty-1')) + socket.binaryType = 'arraybuffer' + const messages: Array = [] + socket.addEventListener('message', (event) => { + messages.push(event.data as string | ArrayBuffer) + }) + + await openEvent(socket) + await waitFor(() => messages.length === 1) + + const closeEvent = new Promise((resolve) => { + socket.addEventListener('close', (event) => resolve(event), { once: true }) + }) + const survivor = vi.fn() + registerSessionConnection(SESSION_ID, survivor) + + closeSessionConnections(SESSION_ID) + await closeEvent + + expect(survivor).toHaveBeenCalledTimes(1) + closeSessionConnections(SESSION_ID) + expect(survivor).toHaveBeenCalledTimes(1) + }) +}) + describe('Repo Terminal Socket Origin validation', () => { const path = `/1/terminals/pty-1/connect?directory=${encodeURIComponent('/tmp/repo')}` diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 66f87fc6a..26db680c7 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -2728,4 +2728,13 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan expect(await res.json()).toEqual({ error: 'Failed to discover models' }) }) }) + + describe('Remote access header routes', () => { + it('serves the remote access header summary under settings', async () => { + const res = await app.request(new Request('http://localhost/remote-access-headers')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ headers: [], unreadable: false }) + }) + }) }) diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts index 95ed9f488..ab962400a 100644 --- a/backend/test/routes/sse.test.ts +++ b/backend/test/routes/sse.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' const sseMocks = vi.hoisted(() => ({ addClient: vi.fn(), @@ -20,14 +21,49 @@ vi.mock('../../src/utils/logger', () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), + debug: vi.fn(), }, })) import { createSSERoutes } from '../../src/routes/sse' +import { createAuthMiddleware } from '../../src/auth/middleware' +import { closeSessionConnections } from '../../src/services/session-connections' +import type { AuthInstance, Session } from '../../src/auth' import { encodeSSEFrame } from '../../src/utils/sse-frame' const decoder = new TextDecoder() +function createSession(sessionId: string): Session { + return { + session: { + id: sessionId, + userId: 'user-1', + token: `token-${sessionId}`, + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }, + user: { + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + role: 'user', + }, + } +} + +function createAuthenticatedApp(sessionId: string) { + const app = new Hono<{ Variables: { session: Session['session']; user: Session['user'] } }>() + app.use('/*', createAuthMiddleware({ + api: { getSession: async () => createSession(sessionId) }, + } as unknown as AuthInstance)) + app.route('/', createSSERoutes()) + return app +} + async function readChunk(reader: { read(): Promise<{ value?: Uint8Array }> }): Promise { const { value } = await reader.read() return decoder.decode(value) @@ -317,4 +353,56 @@ describe('SSE Routes', () => { }) }) }) + + describe('GET /stream session binding', () => { + it('ends the stream once its session is closed', async () => { + const cleanup = vi.fn() + sseMocks.addClient.mockReturnValue(cleanup) + const sessionId = 'session-revoked' + const authenticatedApp = createAuthenticatedApp(sessionId) + + const res = await authenticatedApp.fetch(new Request('http://localhost/stream')) + const reader = res.body!.getReader() + try { + expect(await readChunk(reader)).toContain('event: connected') + + closeSessionConnections(sessionId) + + await vi.waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1)) + expect(await reader.read()).toEqual({ done: true, value: undefined }) + + closeSessionConnections(sessionId) + expect(cleanup).toHaveBeenCalledTimes(1) + } finally { + await reader.cancel() + } + }) + + it('keeps streaming while another session is closed', async () => { + const cleanup = vi.fn() + sseMocks.addClient.mockReturnValue(cleanup) + const sessionId = 'session-active' + const authenticatedApp = createAuthenticatedApp(sessionId) + + const res = await authenticatedApp.fetch(new Request('http://localhost/stream')) + const reader = res.body!.getReader() + try { + expect(await readChunk(reader)).toContain('event: connected') + const [, writeEvent] = sseMocks.addClient.mock.calls[0] as [ + string, + (event: string, data: string) => void, + ] + + closeSessionConnections('session-other') + + writeEvent('message', '{"still":"open"}') + expect(await readChunk(reader)).toBe('event: message\ndata: {"still":"open"}\n\n') + expect(cleanup).not.toHaveBeenCalled() + } finally { + await reader.cancel() + } + + expect(cleanup).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/backend/test/services/notification-subscriptions.test.ts b/backend/test/services/notification-subscriptions.test.ts new file mode 100644 index 000000000..28fe208af --- /dev/null +++ b/backend/test/services/notification-subscriptions.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { NotificationService } from '../../src/services/notification' +import { + deletePushSubscriptionsForSession, + ensurePushSubscriptionsTable, +} from '../../src/db/push-subscriptions' + +const USER_ID = 'user-1' + +const LEGACY_TABLE = ` + CREATE TABLE push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + device_name TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) +` + +function columnNames(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>) + .map((column) => column.name) +} + +function indexNames(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA index_list(${table})`).all() as Array<{ name: string }>) + .map((index) => index.name) +} + +function sessionIdOf(db: Database, endpoint: string): string | null { + const row = db + .prepare('SELECT session_id FROM push_subscriptions WHERE endpoint = ?') + .get(endpoint) as { session_id: string | null } | undefined + expect(row).toBeDefined() + return row!.session_id +} + +function endpointsOf(db: Database): string[] { + return (db.prepare('SELECT endpoint FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string }>) + .map((row) => row.endpoint) +} + +describe('push subscription sessions', () => { + let db: Database + let service: NotificationService + + beforeEach(() => { + db = new Database(':memory:') + service = new NotificationService(db) + }) + + afterEach(() => { + db.close() + }) + + it('stores the session that registered the subscription', () => { + service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone') + + expect(sessionIdOf(db, 'https://push.example.com/phone')).toBe('ses_phone') + }) + + it('stores no session for a subscription registered without one', () => { + service.saveSubscription(USER_ID, 'https://push.example.com/anonymous', 'p256dh-key', 'auth-key', 'Browser') + + expect(sessionIdOf(db, 'https://push.example.com/anonymous')).toBeNull() + }) + + it('replaces the session id when the same endpoint subscribes again', () => { + const endpoint = 'https://push.example.com/phone' + service.saveSubscription(USER_ID, endpoint, 'p256dh-key', 'auth-key', 'Phone', 'ses_old') + service.saveSubscription(USER_ID, endpoint, 'p256dh-key-2', 'auth-key-2', 'Phone', 'ses_new') + + expect(sessionIdOf(db, endpoint)).toBe('ses_new') + expect(service.getSubscriptions(USER_ID)).toHaveLength(1) + }) + + it('adds the session id column to a database created before it existed', () => { + const legacyDb = new Database(':memory:') + legacyDb.run(LEGACY_TABLE) + legacyDb + .prepare( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device_name, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(USER_ID, 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Old Phone', 1, 1) + + new NotificationService(legacyDb) + + expect(columnNames(legacyDb, 'push_subscriptions')).toContain('session_id') + expect(indexNames(legacyDb, 'push_subscriptions')).toContain('idx_push_sub_session') + expect(sessionIdOf(legacyDb, 'https://push.example.com/legacy')).toBeNull() + legacyDb.close() + }) + + it('creates the session id column and index on a fresh database', () => { + expect(columnNames(db, 'push_subscriptions')).toContain('session_id') + expect(indexNames(db, 'push_subscriptions')).toContain('idx_push_sub_session') + }) + + it('leaves the schema unchanged when the table is ensured twice', () => { + ensurePushSubscriptionsTable(db) + ensurePushSubscriptionsTable(db) + + expect(columnNames(db, 'push_subscriptions').filter((name) => name === 'session_id')).toHaveLength(1) + expect(indexNames(db, 'push_subscriptions').filter((name) => name === 'idx_push_sub_session')).toHaveLength(1) + }) + + it('deletes only the subscriptions of the revoked session', () => { + service.saveSubscription(USER_ID, 'https://push.example.com/phone', 'p256dh-key', 'auth-key', 'Phone', 'ses_phone') + service.saveSubscription(USER_ID, 'https://push.example.com/desktop', 'p256dh-key', 'auth-key', 'Desktop', 'ses_desktop') + service.saveSubscription(USER_ID, 'https://push.example.com/legacy', 'p256dh-key', 'auth-key', 'Legacy') + + expect(deletePushSubscriptionsForSession(db, 'ses_phone')).toBe(1) + expect(endpointsOf(db)).toEqual([ + 'https://push.example.com/desktop', + 'https://push.example.com/legacy', + ]) + }) +}) diff --git a/backend/test/services/preview/gateway.test.ts b/backend/test/services/preview/gateway.test.ts index 3c4ce0675..479daaaa8 100644 --- a/backend/test/services/preview/gateway.test.ts +++ b/backend/test/services/preview/gateway.test.ts @@ -42,6 +42,8 @@ upstreamApp.get('/manager-cookies', () => { return new Response('ok', { status: 200, headers }) }) +upstreamApp.get('/redirect', (c) => c.redirect(c.req.query('to') ?? '/', 302)) + upstreamApp.all('/*', async (c) => { if (c.req.path === '/r') { return c.redirect(`http://localhost:${upstreamPort}/done`, 302) @@ -311,6 +313,23 @@ describe('Preview Gateway', () => { expect(res.headers.get('location')).toBe('/done') }) + it('rewrites loopback locations beyond 127.0.0.1 and IPv6 loopback locations to their path', async () => { + const store = new PreviewSessionStore() + const app = createPreviewGatewayApp(store).app + const cookie = await openSession(app, store, upstreamPort) + + for (const host of ['127.0.0.2', '[::1]']) { + const target = `http://${host}:${upstreamPort}/wide?x=1` + const res = await app.request( + `http://localhost/redirect?to=${encodeURIComponent(target)}`, + { headers: { cookie }, redirect: 'manual' }, + ) + + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/wide?x=1') + } + }) + it('returns 502 when the target port is unreachable', async () => { const closedPort = await reserveClosedPort() const store = new PreviewSessionStore() diff --git a/backend/test/services/session-connections.test.ts b/backend/test/services/session-connections.test.ts new file mode 100644 index 000000000..54e04129a --- /dev/null +++ b/backend/test/services/session-connections.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +import { + closeSessionConnections, + registerSessionConnection, +} from '../../src/services/session-connections' +import { logger } from '../../src/utils/logger' + +describe('session connections registry', () => { + beforeEach(() => { + vi.clearAllMocks() + closeSessionConnections('session-a') + closeSessionConnections('session-b') + }) + + it('closes every connection registered for a session', () => { + const first = vi.fn() + const second = vi.fn() + registerSessionConnection('session-a', first) + registerSessionConnection('session-a', second) + + closeSessionConnections('session-a') + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('clears the session so later closes do not re-invoke connections', () => { + const close = vi.fn() + registerSessionConnection('session-a', close) + + closeSessionConnections('session-a') + closeSessionConnections('session-a') + + expect(close).toHaveBeenCalledTimes(1) + }) + + it('leaves other sessions connected', () => { + const closed = vi.fn() + const kept = vi.fn() + registerSessionConnection('session-a', closed) + registerSessionConnection('session-b', kept) + + closeSessionConnections('session-a') + + expect(closed).toHaveBeenCalledTimes(1) + expect(kept).not.toHaveBeenCalled() + + closeSessionConnections('session-b') + expect(kept).toHaveBeenCalledTimes(1) + }) + + it('unregisters a single connection without closing it', () => { + const first = vi.fn() + const second = vi.fn() + const unregisterFirst = registerSessionConnection('session-a', first) + registerSessionConnection('session-a', second) + + unregisterFirst() + closeSessionConnections('session-a') + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('treats a repeated unregister as a no-op', () => { + const close = vi.fn() + const unregister = registerSessionConnection('session-a', close) + + unregister() + unregister() + registerSessionConnection('session-a', close) + closeSessionConnections('session-a') + + expect(close).toHaveBeenCalledTimes(1) + }) + + it('keeps closing the remaining connections when one throws', () => { + const failing = vi.fn(() => { + throw new Error('socket already closed') + }) + const healthy = vi.fn() + registerSessionConnection('session-a', failing) + registerSessionConnection('session-a', healthy) + + expect(() => closeSessionConnections('session-a')).not.toThrow() + + expect(failing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledTimes(1) + }) + + it('ignores sessions without registered connections', () => { + expect(() => closeSessionConnections('unknown-session')).not.toThrow() + expect(logger.error).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/shared/device-pairing-schemas.test.ts b/backend/test/shared/device-pairing-schemas.test.ts new file mode 100644 index 000000000..5cade570c --- /dev/null +++ b/backend/test/shared/device-pairing-schemas.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { + REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH, + REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH, + RedeemDevicePairingCodeResponseSchema, + RemoteAccessHeaderDraftValueSchema, + RemoteAccessHeaderListSchema, + ReplaceRemoteAccessHeadersRequestSchema, + buildManagerBrowserPairingLink, + parseManagerBrowserPairingLink, + readPairingCodeFromHash, +} from '@opencode-manager/shared/schemas' + +const CODE = 'a'.repeat(43) + +function header(name: string, value = 'value') { + return { name, value } +} + +function encodedLength(headers: Array<{ name: string; value: string }>): number { + const params = new URLSearchParams() + for (const entry of headers) params.append('h', `${entry.name}:${entry.value}`) + return params.toString().length +} + +function headersFillingQrLimit(extraCharacters = 0): Array<{ name: string; value: string }> { + const headers = Array.from({ length: 8 }, (_, index) => header(`X-H-${index}`, '')) + let remaining = REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH - encodedLength(headers) + extraCharacters + return headers.map((entry, index) => { + const length = Math.min( + Math.ceil(remaining / (headers.length - index)), + REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH, + ) + remaining -= length + return { name: entry.name, value: 'a'.repeat(length) } + }) +} + +describe('RemoteAccessHeaderListSchema', () => { + it('accepts Cloudflare Access headers and values containing colons', () => { + const result = RemoteAccessHeaderListSchema.safeParse([ + header('CF-Access-Client-Id', 'abc.access'), + header('X-Token', 'a:b:c'), + ]) + + expect(result.success).toBe(true) + }) + + it.each([ + ['a carriage return', 'a\rb'], + ['a line feed', 'a\nb'], + ['a null byte', 'a\0b'], + ])('rejects a value containing %s', (_label, value) => { + expect(RemoteAccessHeaderListSchema.safeParse([header('X-Token', value)]).success).toBe(false) + }) + + it.each(['X Token', 'X:Token', 'X Token:'])('rejects the invalid header name %s', (name) => { + expect(RemoteAccessHeaderListSchema.safeParse([header(name)]).success).toBe(false) + }) + + it.each([ + 'Cookie', + 'set-cookie', + 'HOST', + 'Origin', + 'Content-Length', + 'Content-Type', + 'Connection', + 'Transfer-Encoding', + 'Upgrade', + 'TE', + 'Keep-Alive', + 'Proxy-Authorization', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'Forwarded', + 'X-Real-IP', + 'CF-Connecting-IP', + ])('rejects the blocked header name %s in any case', (name) => { + expect(RemoteAccessHeaderListSchema.safeParse([header(name)]).success).toBe(false) + }) + + it('rejects more than eight headers', () => { + const headers = Array.from({ length: 9 }, (_, index) => header(`X-Header-${index}`)) + expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false) + }) + + it('accepts a typical Cloudflare Access header pair', () => { + const headers = [ + header('CF-Access-Client-Id', `${'a'.repeat(64)}.access`), + header('CF-Access-Client-Secret', 'b'.repeat(64)), + ] + + expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(true) + expect(encodedLength(headers)).toBeLessThan(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH) + }) + + it('accepts headers that encode to exactly the QR limit', () => { + const headers = headersFillingQrLimit() + + expect(encodedLength(headers)).toBe(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH) + expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(true) + }) + + it('rejects headers that encode to one character over the QR limit', () => { + const headers = headersFillingQrLimit(1) + + expect(encodedLength(headers)).toBe(REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH + 1) + expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false) + }) + + it('rejects eight 512-character values because they no longer fit in the QR code', () => { + const headers = Array.from({ length: 8 }, (_, index) => header(`X-Header-${index}`, 'a'.repeat(512))) + + expect(RemoteAccessHeaderListSchema.safeParse(headers).success).toBe(false) + }) + + it('reports the encoded limit when the list is too long for a QR code', () => { + const result = RemoteAccessHeaderListSchema.safeParse(headersFillingQrLimit(1)) + + expect(result.error?.issues.map((issue) => issue.message)).toContain( + `Remote access headers are too long to fit in a QR code (limit ${REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH} encoded characters)`, + ) + }) + + it('rejects a 513-character value', () => { + expect(RemoteAccessHeaderListSchema.safeParse([header('X-Token', 'a'.repeat(513))]).success).toBe(false) + }) + + it('rejects duplicate names that differ only in case', () => { + expect( + RemoteAccessHeaderListSchema.safeParse([header('X-Token', 'one'), header('x-token', 'two')]).success, + ).toBe(false) + }) +}) + +describe('RemoteAccessHeaderDraftValueSchema', () => { + it('accepts a blank value, meaning the stored value is kept', () => { + expect(RemoteAccessHeaderDraftValueSchema.safeParse(' ')).toEqual({ success: true, data: '' }) + }) + + it('trims a provided value', () => { + const result = RemoteAccessHeaderDraftValueSchema.safeParse(' secret ') + + expect(result.success && result.data).toBe('secret') + }) + + it.each([ + ['a carriage return', 'a\rb'], + ['a line feed', 'a\nb'], + ['a null byte', 'a\0b'], + ])('rejects a value containing %s', (_label, value) => { + expect(RemoteAccessHeaderDraftValueSchema.safeParse(value).success).toBe(false) + }) + + it('accepts a draft entry without a value in the replace request', () => { + const result = ReplaceRemoteAccessHeadersRequestSchema.safeParse({ + headers: [{ name: 'CF-Access-Client-Id' }, { name: 'CF-Access-Client-Secret', value: 'secret' }], + }) + + expect(result.success).toBe(true) + }) + + it('rejects a draft entry whose value contains a line break', () => { + const result = ReplaceRemoteAccessHeadersRequestSchema.safeParse({ + headers: [{ name: 'CF-Access-Client-Secret', value: 'sec\r\nret' }], + }) + + expect(result.success).toBe(false) + }) +}) + +describe('RedeemDevicePairingCodeResponseSchema', () => { + it('parses the user returned by the redeem endpoint', () => { + const response = { user: { id: 'user_1', name: 'Chris', email: 'chris@example.com' } } + + expect(RedeemDevicePairingCodeResponseSchema.parse(response)).toEqual(response) + }) + + it('rejects a response without a user email', () => { + expect(RedeemDevicePairingCodeResponseSchema.safeParse({ user: { id: 'user_1', name: 'Chris' } }).success).toBe( + false, + ) + }) +}) + +describe('buildManagerBrowserPairingLink', () => { + it('builds a browser pairing link with the code in the fragment', () => { + expect(buildManagerBrowserPairingLink('https://m.example.com', CODE)).toBe( + `https://m.example.com/pair#c=${CODE}`, + ) + }) + + it('trims trailing slashes and whitespace from the origin', () => { + expect(buildManagerBrowserPairingLink(' https://m.example.com/ ', CODE)).toBe( + `https://m.example.com/pair#c=${CODE}`, + ) + }) +}) + +describe('parseManagerBrowserPairingLink', () => { + it('parses a browser pairing link built by the shared builder', () => { + expect(parseManagerBrowserPairingLink(buildManagerBrowserPairingLink('https://m.example.com', CODE))).toEqual({ + origin: 'https://m.example.com', + code: CODE, + }) + }) + + it.each([ + ['a wrong path', `https://m.example.com/other#c=${CODE}`], + ['a missing code', 'https://m.example.com/pair'], + ['an empty code', 'https://m.example.com/pair#c='], + ['a non-http scheme', `ocm://link?v=2&kind=manager#c=${CODE}`], + ['garbage text', 'not a pairing link'], + ])('returns null for %s', (_label, text) => { + expect(parseManagerBrowserPairingLink(text)).toBeNull() + }) +}) + +describe('readPairingCodeFromHash', () => { + it('reads a valid code from the fragment', () => { + expect(readPairingCodeFromHash(`#c=${CODE}`)).toBe(CODE) + }) + + it('returns null for a missing or invalid code', () => { + expect(readPairingCodeFromHash('')).toBeNull() + expect(readPairingCodeFromHash('#c=short')).toBeNull() + }) +}) diff --git a/backend/test/shared/manager-app-link.test.ts b/backend/test/shared/manager-app-link.test.ts new file mode 100644 index 000000000..5adbb3893 --- /dev/null +++ b/backend/test/shared/manager-app-link.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + OPENCODE_SERVER_LINK_VERSION, + buildManagerAppLink, + buildOpenCodeServerLink, + parseManagerAppLink, + parseOpenCodeServerLink, + type ManagerAppLink, +} from '@opencode-manager/shared/schemas' + +const CODE = 'a'.repeat(43) + +const MANAGER_LINK: ManagerAppLink = { + kind: 'manager', + name: 'Home', + baseUrl: 'https://m.example.com', + code: CODE, + headers: [ + { name: 'CF-Access-Client-Id', value: 'id.access' }, + { name: 'X-Secret', value: 'a:b=c&d' }, + ], +} + +const OPENCODE_LINK = { + kind: 'opencode' as const, + name: 'build-host', + baseUrl: 'http://192.168.1.20:4096', + pairing: { pairUrl: 'http://127.0.0.1:4096/redeem', code: CODE }, +} + +function managerLink(extra: Record): string { + const params = new URLSearchParams({ + v: OPENCODE_SERVER_LINK_VERSION, + kind: 'manager', + name: 'Home', + url: 'https://m.example.com', + code: CODE, + }) + for (const [key, value] of Object.entries(extra)) { + params.delete(key) + for (const item of Array.isArray(value) ? value : [value]) params.append(key, item) + } + return `ocm://link?${params.toString()}` +} + +describe('buildManagerAppLink', () => { + it('builds an ocm link carrying the version and the manager kind', () => { + const link = buildManagerAppLink(MANAGER_LINK) + const params = new URL(link).searchParams + + expect(link.startsWith('ocm://link?')).toBe(true) + expect(params.get('v')).toBe(OPENCODE_SERVER_LINK_VERSION) + expect(params.get('kind')).toBe('manager') + }) + + it('keeps the exact wire format of a manager link', () => { + expect(buildManagerAppLink(MANAGER_LINK)).toBe( + `ocm://link?v=2&kind=manager&name=Home&url=https%3A%2F%2Fm.example.com&code=${CODE}` + + '&h=CF-Access-Client-Id%3Aid.access&h=X-Secret%3Aa%3Ab%3Dc%26d', + ) + }) + + it('appends one h parameter per header in order', () => { + const params = new URL(buildManagerAppLink(MANAGER_LINK)).searchParams + + expect(params.getAll('h')).toEqual(['CF-Access-Client-Id:id.access', 'X-Secret:a:b=c&d']) + }) +}) + +describe('buildOpenCodeServerLink', () => { + it('keeps the exact wire format of an opencode link', () => { + expect(buildOpenCodeServerLink(OPENCODE_LINK)).toBe( + `ocm://link?v=2&kind=opencode&name=build-host&url=http%3A%2F%2F192.168.1.20%3A4096` + + `&pair=http%3A%2F%2F127.0.0.1%3A4096%2Fredeem&code=${CODE}`, + ) + }) +}) + +describe('parseManagerAppLink', () => { + it('round-trips a manager link with headers in order', () => { + expect(parseManagerAppLink(buildManagerAppLink(MANAGER_LINK))).toEqual(MANAGER_LINK) + }) + + it('round-trips a manager link without headers', () => { + const link = { ...MANAGER_LINK, headers: [] } + + expect(parseManagerAppLink(buildManagerAppLink(link))).toEqual(link) + }) + + it.each([ + ['an opencode link', buildOpenCodeServerLink(OPENCODE_LINK)], + ['a wrong version', managerLink({ v: '3' })], + ['a header without a colon', managerLink({ h: 'X-Secret' })], + ['a blocked header name', managerLink({ h: 'Cookie:session' })], + ['a forwarding header name', managerLink({ h: 'X-Forwarded-For:1.2.3.4' })], + ['more than eight headers', managerLink({ h: Array.from({ length: 9 }, (_, index) => `X-Header-${index}:value`) })], + ['garbage text', 'not a link'], + ])('returns null for %s', (_label, text) => { + expect(parseManagerAppLink(text)).toBeNull() + }) +}) + +describe('manager and opencode link kinds', () => { + it('does not parse a manager link as an opencode link', () => { + expect(parseOpenCodeServerLink(buildManagerAppLink(MANAGER_LINK))).toBeNull() + }) + + it('keeps the opencode link round trip unchanged', () => { + expect(parseOpenCodeServerLink(buildOpenCodeServerLink(OPENCODE_LINK))).toEqual(OPENCODE_LINK) + }) +}) diff --git a/backend/test/shared/trusted-origin.test.ts b/backend/test/shared/trusted-origin.test.ts new file mode 100644 index 000000000..baccab583 --- /dev/null +++ b/backend/test/shared/trusted-origin.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env' + +const TRUSTED = ['https://m.example.com', 'http://localhost:5173'] as const + +describe('isTrustedOrigin', () => { + it('accepts a listed origin', () => { + expect(isTrustedOrigin('https://m.example.com', {}, TRUSTED)).toBe(true) + expect(isTrustedOrigin('https://m.example.com', { requireOrigin: true }, TRUSTED)).toBe(true) + }) + + it('rejects an unlisted origin', () => { + expect(isTrustedOrigin('https://evil.example', {}, TRUSTED)).toBe(false) + expect(isTrustedOrigin('https://evil.example', { requireOrigin: true }, TRUSTED)).toBe(false) + }) + + it('treats a missing origin as trusted unless an origin is required', () => { + expect(isTrustedOrigin(undefined, {}, TRUSTED)).toBe(true) + expect(isTrustedOrigin(undefined, { requireOrigin: true }, TRUSTED)).toBe(false) + }) + + it('treats an empty origin as missing', () => { + expect(isTrustedOrigin('', {}, TRUSTED)).toBe(true) + expect(isTrustedOrigin('', { requireOrigin: true }, TRUSTED)).toBe(false) + }) + + it('treats the null origin as untrusted', () => { + expect(isTrustedOrigin('null', {}, TRUSTED)).toBe(false) + expect(isTrustedOrigin('null', { requireOrigin: true }, [...TRUSTED, 'null'])).toBe(false) + }) + + it('reads the configured origins by default', () => { + const configured = getTrustedOrigins() + expect(configured.length).toBeGreaterThan(0) + expect(isTrustedOrigin(configured[0])).toBe(true) + expect(isTrustedOrigin('https://not-configured.example')).toBe(false) + }) +}) diff --git a/backend/test/shared/url-hosts.test.ts b/backend/test/shared/url-hosts.test.ts new file mode 100644 index 000000000..bc69ca7e8 --- /dev/null +++ b/backend/test/shared/url-hosts.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils' + +describe('isLoopbackHost', () => { + it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.4.5.6', '::1', '[::1]'])( + 'treats %s as loopback', + (host) => { + expect(isLoopbackHost(host)).toBe(true) + }, + ) + + it.each(['192.168.1.2', 'example.com', '100.64.0.1', '10.0.0.1'])( + 'treats %s as not loopback', + (host) => { + expect(isLoopbackHost(host)).toBe(false) + }, + ) +}) + +describe('isPrivateNetworkHost', () => { + it.each([ + '10.0.0.1', + '172.16.0.1', + '172.31.255.255', + '192.168.1.20', + '169.254.10.1', + '100.64.0.1', + '100.127.255.255', + 'fd7a:115c:a1e0::1', + '[fd7a:115c:a1e0::1]', + 'fc00::1', + ])('treats %s as private', (host) => { + expect(isPrivateNetworkHost(host)).toBe(true) + }) + + it.each([ + '172.32.0.1', + '100.128.0.1', + '8.8.8.8', + '127.0.0.1', + '::1', + 'fe80::1', + 'example.ts.net', + 'host.local', + '256.1.1.1', + '10.0.0', + ])('treats %s as public', (host) => { + expect(isPrivateNetworkHost(host)).toBe(false) + }) +}) diff --git a/backend/test/shared/user-agent.test.ts b/backend/test/shared/user-agent.test.ts new file mode 100644 index 000000000..94b85e276 --- /dev/null +++ b/backend/test/shared/user-agent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { describeUserAgent } from '@opencode-manager/shared/utils' + +const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1' +const IPHONE_CHROME = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1' +const ANDROID_CHROME = + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36' +const MACOS_FIREFOX = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0' +const WINDOWS_EDGE = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.80' + +describe('describeUserAgent', () => { + it('describes iPhone Safari', () => { + expect(describeUserAgent(IPHONE_SAFARI)).toBe('Safari on iPhone') + }) + + it('describes iPhone Chrome', () => { + expect(describeUserAgent(IPHONE_CHROME)).toBe('Chrome on iPhone') + }) + + it('describes Android Chrome without reporting Linux', () => { + expect(describeUserAgent(ANDROID_CHROME)).toBe('Chrome on Android') + }) + + it('describes macOS Firefox', () => { + expect(describeUserAgent(MACOS_FIREFOX)).toBe('Firefox on macOS') + }) + + it('describes Windows Edge before Chrome', () => { + expect(describeUserAgent(WINDOWS_EDGE)).toBe('Edge on Windows') + }) + + it('falls back to Unknown device for empty input', () => { + expect(describeUserAgent(null)).toBe('Unknown device') + expect(describeUserAgent('')).toBe('Unknown device') + }) + + it('returns an unrecognized user agent verbatim', () => { + expect(describeUserAgent('curl/8.4.0')).toBe('curl/8.4.0') + }) + + it('truncates a long unrecognized user agent to 80 characters', () => { + const result = describeUserAgent('x'.repeat(120)) + expect(result).toBe('x'.repeat(80)) + expect(result.length).toBe(80) + }) +}) diff --git a/frontend/package.json b/frontend/package.json index 33d670686..c81dd3aa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "uqr": "^0.1.3", "zod": "^4.1.12", "zustand": "^5.0.8" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ddb4e713c..e203f12e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation, useParams, Navigate } from 'react-router-dom' import { useEffect, useRef, useCallback, type ReactNode } from 'react' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { DEVICE_PAIRING_PATH, LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' @@ -12,6 +12,7 @@ import { SessionDetail } from './pages/SessionDetail' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' import { Login } from './pages/Login' +import { Pair } from './pages/Pair' import { Register } from './pages/Register' import { Setup } from './pages/Setup' import { AssistantRedirect } from './pages/AssistantRedirect' @@ -290,6 +291,10 @@ const router = createBrowserRouter([ element: , loader: loginLoader, }, + { + path: DEVICE_PAIRING_PATH, + element: , + }, { path: '/register', element: , diff --git a/frontend/src/api/devicePairing.ts b/frontend/src/api/devicePairing.ts new file mode 100644 index 000000000..77543f270 --- /dev/null +++ b/frontend/src/api/devicePairing.ts @@ -0,0 +1,80 @@ +import type { + CreateDevicePairingLinksRequest, + DevicePairingLinksResponse, + PairedDevice, + PairedDevicesResponse, + RedeemDevicePairingCodeRequest, + RedeemDevicePairingCodeResponse, + RemoteAccessHeadersResponse, + ReplaceRemoteAccessHeadersRequest, +} from '@opencode-manager/shared/schemas' +import { API_BASE_URL } from '@/config' +import { getAuthBaseUrl } from '@/lib/auth-client' +import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' + +const REMOTE_ACCESS_HEADERS_URL = `${API_BASE_URL}/api/settings/remote-access-headers` + +/** Redeems a browser pairing code and starts a session for the paired user. */ +export async function redeemDevicePairingCode( + body: RedeemDevicePairingCodeRequest, +): Promise { + return fetchWrapper( + `${getAuthBaseUrl()}/device-pairing/redeem`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ) +} + +/** Reads the names of the stored remote-access headers without their values. */ +export async function getRemoteAccessHeaders(): Promise { + return fetchWrapper(REMOTE_ACCESS_HEADERS_URL) +} + +/** Replaces the stored remote-access headers, keeping stored values for blank entries. */ +export async function replaceRemoteAccessHeaders( + body: ReplaceRemoteAccessHeadersRequest, +): Promise { + return fetchWrapper(REMOTE_ACCESS_HEADERS_URL, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +/** Removes every stored remote-access header. */ +export async function clearRemoteAccessHeaders(): Promise { + return fetchWrapper(REMOTE_ACCESS_HEADERS_URL, { + method: 'DELETE', + }) +} + +/** Mints a pairing code and builds the app and browser links for this origin. */ +export async function createDevicePairingLinks( + body: CreateDevicePairingLinksRequest = {}, +): Promise { + return fetchWrapper(`${API_BASE_URL}/api/device-pairing/links`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +/** Lists the devices signed in to this account. */ +export async function listPairedDevices(): Promise { + const response = await fetchWrapper( + `${getAuthBaseUrl()}/device-pairing/devices`, + ) + return response.devices +} + +/** Signs one paired device out. */ +export async function revokePairedDevice(id: string): Promise { + return fetchWrapperVoid(`${getAuthBaseUrl()}/device-pairing/devices/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) +} diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx index 4c8b5db8d..516707a75 100644 --- a/frontend/src/components/settings/AccountSettings.tsx +++ b/frontend/src/components/settings/AccountSettings.tsx @@ -10,6 +10,9 @@ import { DeleteDialog } from '@/components/ui/delete-dialog' import { Loader2, User, KeyRound, LogOut, Plus, Trash2, AlertCircle, CheckCircle, Lock } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { passkey, changePassword } from '@/lib/auth-client' +import { ConnectDeviceSettings } from './ConnectDeviceSettings' +import { PairedDevicesSettings } from './PairedDevicesSettings' +import { RemoteAccessHeadersSettings } from './RemoteAccessHeadersSettings' interface Passkey { id: string @@ -343,6 +346,12 @@ export function AccountSettings() { +
+ + + +
+
diff --git a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx new file mode 100644 index 000000000..a25745610 --- /dev/null +++ b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { DevicePairingLinksResponse } from '@opencode-manager/shared/schemas' +import { ConnectDeviceDialog } from './ConnectDeviceDialog' + +const mocks = vi.hoisted(() => ({ + createDevicePairingLinks: vi.fn(), +})) + +vi.mock('@/api/devicePairing', () => ({ + createDevicePairingLinks: mocks.createDevicePairingLinks, +})) + +const BASE_LINKS: DevicePairingLinksResponse = { + appLink: 'ocm://link?v=2&kind=manager&name=studio&url=https%3A%2F%2Fstudio.example.com&code=abcdefghijklmnop', + appLinkBlockedReason: null, + browserLink: 'https://studio.example.com/pair#c=abcdefghijklmnop', + expiresAt: new Date(Date.now() + 179_000).toISOString(), + headerNames: ['CF-Access-Client-Id', 'CF-Access-Client-Secret'], + warnings: ['plain-http'], +} + +function renderDialog() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('ConnectDeviceDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createDevicePairingLinks.mockResolvedValue(BASE_LINKS) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('requests pairing links once when it opens', async () => { + renderDialog() + + await screen.findByAltText('Mobile app pairing QR code') + + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1) + }) + + it('hides the mobile app QR code until it is revealed', async () => { + const user = userEvent.setup() + renderDialog() + + const qrCode = await screen.findByAltText('Mobile app pairing QR code') + expect(qrCode).toHaveClass('blur-md') + + await user.click(screen.getByRole('button', { name: 'Reveal QR code' })) + + expect(qrCode).not.toHaveClass('blur-md') + }) + + it('warns about screen sharing and names the included headers', async () => { + renderDialog() + + expect( + await screen.findByText( + "This QR code contains your remote access headers. Don't show it while screen sharing or recording.", + ), + ).toBeInTheDocument() + expect( + screen.getByText('Includes headers: CF-Access-Client-Id, CF-Access-Client-Secret'), + ).toBeInTheDocument() + }) + + it('shows the browser sign-in QR code on the browser tab', async () => { + const user = userEvent.setup() + renderDialog() + + await screen.findByAltText('Mobile app pairing QR code') + await user.click(screen.getByRole('tab', { name: 'Browser / PWA' })) + + expect(await screen.findByAltText('Browser sign-in QR code')).toBeInTheDocument() + }) + + it('explains a loopback block without showing the mobile app QR code', async () => { + mocks.createDevicePairingLinks.mockResolvedValue({ + ...BASE_LINKS, + appLink: null, + appLinkBlockedReason: 'loopback', + warnings: ['plain-http', 'loopback'], + }) + renderDialog() + + expect( + await screen.findByText( + 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.', + ), + ).toBeInTheDocument() + expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument() + expect( + screen.getByText('This address uses plain HTTP, so the sign-in code travels unencrypted.'), + ).toBeInTheDocument() + expect( + screen.getByText('This address is localhost; other devices cannot reach it.'), + ).toBeInTheDocument() + }) + + it('explains how to unblock a plain-HTTP app link', async () => { + mocks.createDevicePairingLinks.mockResolvedValue({ + ...BASE_LINKS, + appLink: null, + appLinkBlockedReason: 'insecure-origin', + warnings: ['plain-http'], + }) + renderDialog() + + expect( + await screen.findByText( + "The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address.", + ), + ).toBeInTheDocument() + }) + + it('warns when the stored remote access headers are unreadable', async () => { + mocks.createDevicePairingLinks.mockResolvedValue({ + ...BASE_LINKS, + headerNames: [], + warnings: ['plain-http', 'headers-unreadable'], + }) + renderDialog() + + expect( + await screen.findByText( + 'Stored remote access headers could not be decrypted and are not included.', + ), + ).toBeInTheDocument() + }) + + it('counts down and requests a new link after it expires', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + renderDialog() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(screen.getByText('Expires in 3:00')).toBeInTheDocument() + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(180_000) + }) + + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(2) + }) + + it('times expiry on the client clock when the client clock is ahead of the server', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + mocks.createDevicePairingLinks.mockImplementation(async () => ({ + ...BASE_LINKS, + expiresAt: new Date(Date.now() - 600_000).toISOString(), + })) + + renderDialog() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1) + expect(screen.getByText('Expires in 2:55')).toBeInTheDocument() + }) + + it('stops the countdown while the links are loading and after a failure', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + let rejectRequest: ((error: Error) => void) | undefined + mocks.createDevicePairingLinks.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectRequest = reject + }), + ) + + renderDialog() + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + + expect(screen.getByText('Creating a pairing code…')).toBeInTheDocument() + expect(screen.queryByText(/^Expires in/)).not.toBeInTheDocument() + + await act(async () => { + rejectRequest?.(new Error('Failed to create pairing links')) + await vi.advanceTimersByTimeAsync(65_000) + }) + + expect(screen.getByText('Failed to create pairing links')).toBeInTheDocument() + expect(screen.queryByText(/^Expires in/)).not.toBeInTheDocument() + expect(mocks.createDevicePairingLinks).toHaveBeenCalledTimes(1) + }) + + it('shows a failed request with a retry button', async () => { + const user = userEvent.setup() + mocks.createDevicePairingLinks.mockRejectedValueOnce(new Error('Failed to create pairing links')) + renderDialog() + + expect(await screen.findByText('Failed to create pairing links')).toBeInTheDocument() + + mocks.createDevicePairingLinks.mockResolvedValueOnce(BASE_LINKS) + await user.click(screen.getByRole('button', { name: 'Try again' })) + + expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/ConnectDeviceDialog.tsx b/frontend/src/components/settings/ConnectDeviceDialog.tsx new file mode 100644 index 000000000..cd4ed7686 --- /dev/null +++ b/frontend/src/components/settings/ConnectDeviceDialog.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { AlertCircle, Loader2, QrCode, RefreshCw } from 'lucide-react' +import { + DEVICE_PAIRING_CODE_TTL_SECONDS, + type DevicePairingLinkWarning, + type DevicePairingLinksResponse, +} from '@opencode-manager/shared/schemas' +import { useCreateDevicePairingLinks, useInvalidatePairedDevices } from '@/hooks/useDevicePairing' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { PairingQrCode } from './PairingQrCode' + +const WARNING_MESSAGES: Record = { + 'plain-http': 'This address uses plain HTTP, so the sign-in code travels unencrypted.', + loopback: 'This address is localhost; other devices cannot reach it.', + 'headers-unreadable': 'Stored remote access headers could not be decrypted and are not included.', +} + +const LOOPBACK_EXPLANATION = + 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.' + +const INSECURE_ORIGIN_EXPLANATION = + "The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address." + +const SCREEN_SHARING_WARNING = + "This QR code contains your remote access headers. Don't show it while screen sharing or recording." + +const BROWSER_HINT = + "Scan with your phone camera to sign in the browser or installed app. In an iPhone home-screen app, use 'Sign in with QR code' on its sign-in screen." + +const LINKS_FAILED_MESSAGE = 'Failed to create pairing links' + +interface ConnectDeviceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +function formatCountdown(localExpiresAt: number, now: number): string { + const remainingSeconds = Math.max(0, Math.ceil((localExpiresAt - now) / 1000)) + const minutes = Math.floor(remainingSeconds / 60) + return `${minutes}:${String(remainingSeconds % 60).padStart(2, '0')}` +} + +export function ConnectDeviceDialog({ open, onOpenChange }: ConnectDeviceDialogProps) { + const invalidatePairedDevices = useInvalidatePairedDevices() + const { mutate, isPending } = useCreateDevicePairingLinks() + const [links, setLinks] = useState(null) + const [localExpiresAt, setLocalExpiresAt] = useState(null) + const [error, setError] = useState(null) + const [revealed, setRevealed] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const wasOpenRef = useRef(false) + + const requestLinks = useCallback(() => { + setError(null) + mutate( + {}, + { + onSuccess: (response) => { + const receivedAt = Date.now() + setLinks(response) + setLocalExpiresAt(receivedAt + DEVICE_PAIRING_CODE_TTL_SECONDS * 1000) + setNow(receivedAt) + }, + onError: (requestError) => { + setLinks(null) + setLocalExpiresAt(null) + setError( + requestError instanceof Error && requestError.message + ? requestError.message + : LINKS_FAILED_MESSAGE, + ) + }, + }, + ) + }, [mutate]) + + useEffect(() => { + if (open && !wasOpenRef.current) { + wasOpenRef.current = true + setLinks(null) + setLocalExpiresAt(null) + setRevealed(false) + requestLinks() + return + } + + if (!open && wasOpenRef.current) { + wasOpenRef.current = false + setLinks(null) + setLocalExpiresAt(null) + setError(null) + setRevealed(false) + void invalidatePairedDevices() + } + }, [invalidatePairedDevices, open, requestLinks]) + + useEffect(() => { + if (!open || !links) return undefined + const interval = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(interval) + }, [links, open]) + + useEffect(() => { + if (!open || !links || localExpiresAt === null || isPending) return + if (now < localExpiresAt) return + requestLinks() + }, [isPending, links, localExpiresAt, now, open, requestLinks]) + + return ( + + + + + + Connect a device + + + Scan a code with the mobile app or your phone camera to sign in. + + + + {error ? ( +
+ + + {error} + + +
+ ) : !links ? ( +
+ +

Creating a pairing code…

+
+ ) : ( +
+ {localExpiresAt !== null && ( +

+ Expires in {formatCountdown(localExpiresAt, now)} +

+ )} + + + + + Mobile app + + + Browser / PWA + + + + + {links.appLink ? ( + <> +
+ +
+ +

{SCREEN_SHARING_WARNING}

+

+ {links.headerNames.length > 0 + ? `Includes headers: ${links.headerNames.join(', ')}` + : 'No remote access headers are included.'} +

+ + ) : ( +

+ {links.appLinkBlockedReason === 'loopback' + ? LOOPBACK_EXPLANATION + : INSECURE_ORIGIN_EXPLANATION} +

+ )} +
+ + +
+ +
+

{BROWSER_HINT}

+
+
+ + {links.warnings.map((warning) => ( +

+ {WARNING_MESSAGES[warning]} +

+ ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx new file mode 100644 index 000000000..6690d2f49 --- /dev/null +++ b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { DEFAULT_USER_PREFERENCES } from '@opencode-manager/shared/schemas' +import { createUseSettingsMock } from '@/test/test-utils' +import { ConnectDeviceSettings } from './ConnectDeviceSettings' + +const mocks = vi.hoisted(() => ({ + useSettings: vi.fn(), + updateSettings: vi.fn(), + createDevicePairingLinks: vi.fn(), +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: mocks.useSettings, +})) + +vi.mock('@/api/devicePairing', () => ({ + createDevicePairingLinks: mocks.createDevicePairingLinks, +})) + +function renderSettings() { + mocks.useSettings.mockReturnValue( + createUseSettingsMock({ + preferences: { + ...DEFAULT_USER_PREFERENCES, + devicePairing: { allowPrivateNetworkHttp: false }, + }, + updateSettings: mocks.updateSettings, + }), + ) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('ConnectDeviceSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createDevicePairingLinks.mockResolvedValue({ + appLink: 'ocm://link?v=2&kind=manager&name=studio&url=https%3A%2F%2Fstudio.example.com&code=abcdefghijklmnop', + appLinkBlockedReason: null, + browserLink: 'https://studio.example.com/pair#c=abcdefghijklmnop', + expiresAt: new Date(Date.now() + 179_000).toISOString(), + headerNames: [], + warnings: [], + }) + }) + + it('persists the private-network plain-HTTP preference', async () => { + const user = userEvent.setup() + renderSettings() + + await user.click( + screen.getByRole('switch', { name: 'Allow plain-HTTP app links on private networks' }), + ) + + expect(mocks.updateSettings).toHaveBeenCalledWith({ + devicePairing: { allowPrivateNetworkHttp: true }, + }) + }) + + it('opens the pairing dialog from the QR code button', async () => { + const user = userEvent.setup() + renderSettings() + + await user.click(screen.getByRole('button', { name: 'Show QR code' })) + + expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/ConnectDeviceSettings.tsx b/frontend/src/components/settings/ConnectDeviceSettings.tsx new file mode 100644 index 000000000..80624a7d5 --- /dev/null +++ b/frontend/src/components/settings/ConnectDeviceSettings.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import { QrCode } from 'lucide-react' +import { useSettings } from '@/hooks/useSettings' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { ConnectDeviceDialog } from './ConnectDeviceDialog' + +const ALLOW_PRIVATE_NETWORK_HTTP_LABEL = 'Allow plain-HTTP app links on private networks' + +export function ConnectDeviceSettings() { + const { preferences, updateSettings } = useSettings() + const [dialogOpen, setDialogOpen] = useState(false) + const allowPrivateNetworkHttp = preferences?.devicePairing?.allowPrivateNetworkHttp ?? false + + return ( + + + + + Connect a device + + + Pair the mobile app or sign another browser in with a QR code. + + + + + +
+
+ +

+ Lets the mobile app QR code use a LAN address without HTTPS. +

+
+ + updateSettings({ devicePairing: { allowPrivateNetworkHttp: checked } }) + } + /> +
+
+ + +
+ ) +} diff --git a/frontend/src/components/settings/OpenCodeServerDialog.test.tsx b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx index 24aae1a81..b71071dd6 100644 --- a/frontend/src/components/settings/OpenCodeServerDialog.test.tsx +++ b/frontend/src/components/settings/OpenCodeServerDialog.test.tsx @@ -168,6 +168,29 @@ describe('OpenCodeServerDialog', () => { expect(screen.queryByText(originChangeWarning)).not.toBeInTheDocument() }) + it('warns about unencrypted credentials only for public HTTP origins', async () => { + const user = userEvent.setup() + const transportWarning = 'This URL is not HTTPS, so credentials travel unencrypted' + renderDialog() + + const urlInput = screen.getByLabelText('URL') + + await user.type(urlInput, 'http://203.0.113.5:4096') + expect(screen.getByText(new RegExp(transportWarning))).toBeInTheDocument() + + await user.clear(urlInput) + await user.type(urlInput, 'http://192.168.1.20:4096') + expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument() + + await user.clear(urlInput) + await user.type(urlInput, 'http://100.101.102.103:4096') + expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument() + + await user.clear(urlInput) + await user.type(urlInput, 'https://studio.example.com') + expect(screen.queryByText(new RegExp(transportWarning))).not.toBeInTheDocument() + }) + it('does not warn about an origin change when no password is stored', async () => { const user = userEvent.setup() renderDialog({ server: { ...savedServer, hasPassword: false } }) diff --git a/frontend/src/components/settings/OpenCodeServerDialog.tsx b/frontend/src/components/settings/OpenCodeServerDialog.tsx index 8c22a20ff..428629861 100644 --- a/frontend/src/components/settings/OpenCodeServerDialog.tsx +++ b/frontend/src/components/settings/OpenCodeServerDialog.tsx @@ -12,7 +12,7 @@ import { type OpenCodeServerLink, type OpenCodeServerTestResult, } from '@opencode-manager/shared/schemas' -import { haveSameUrlOrigin } from '@opencode-manager/shared/utils' +import { haveSameUrlOrigin, isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -84,26 +84,14 @@ const serverFormSchema = z.object({ type ServerFormValues = z.infer -function isPrivateHost(hostname: string): boolean { - const host = hostname.toLowerCase() - if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') return true - const octets = host.split('.').map((part) => Number(part)) - if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { - return false - } - const [first, second] = octets - return ( - first === 10 || - (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 168) || - (first === 169 && second === 254) - ) -} - function sendsCredentialsInClear(baseUrl: string): boolean { try { const url = new URL(baseUrl) - return url.protocol === 'http:' && !isPrivateHost(url.hostname) + return ( + url.protocol === 'http:' && + !isLoopbackHost(url.hostname) && + !isPrivateNetworkHost(url.hostname) + ) } catch { return false } diff --git a/frontend/src/components/settings/PairedDevicesSettings.test.tsx b/frontend/src/components/settings/PairedDevicesSettings.test.tsx new file mode 100644 index 000000000..53f603ad6 --- /dev/null +++ b/frontend/src/components/settings/PairedDevicesSettings.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { PairedDevice } from '@opencode-manager/shared/schemas' +import { PairedDevicesSettings } from './PairedDevicesSettings' + +const mocks = vi.hoisted(() => ({ + listPairedDevices: vi.fn(), + revokePairedDevice: vi.fn(), +})) + +vi.mock('@/api/devicePairing', () => ({ + listPairedDevices: mocks.listPairedDevices, + revokePairedDevice: mocks.revokePairedDevice, +})) + +const IPHONE_SAFARI_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' + +const CURRENT_DEVICE: PairedDevice = { + id: 'session-current', + deviceName: 'Chris MacBook', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + ipAddress: '192.168.1.10', + createdAt: '2026-10-01T10:00:00.000Z', + updatedAt: '2026-10-20T10:00:00.000Z', + isCurrent: true, +} + +const IPHONE_DEVICE: PairedDevice = { + id: 'session-iphone', + deviceName: null, + userAgent: IPHONE_SAFARI_UA, + ipAddress: '192.168.1.44', + createdAt: '2026-10-02T10:00:00.000Z', + updatedAt: '2026-10-21T10:00:00.000Z', + isCurrent: false, +} + +function renderSettings() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('PairedDevicesSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE]) + mocks.revokePairedDevice.mockResolvedValue(undefined) + }) + + it('shows the device name when one is set', async () => { + renderSettings() + + expect(await screen.findByText('Chris MacBook')).toBeInTheDocument() + }) + + it('shows when the device signed in', async () => { + mocks.listPairedDevices.mockResolvedValue([{ ...CURRENT_DEVICE, ipAddress: null }]) + renderSettings() + + expect(await screen.findByText(/^Signed in /)).toBeInTheDocument() + }) + + it('falls back to a user-agent label when the device has no name', async () => { + mocks.listPairedDevices.mockResolvedValue([IPHONE_DEVICE]) + renderSettings() + + expect(await screen.findByText('Safari on iPhone')).toBeInTheDocument() + }) + + it('marks the current device and hides its revoke button', async () => { + mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE]) + renderSettings() + + expect(await screen.findByText('This device')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Revoke / })).not.toBeInTheDocument() + }) + + it('revokes a paired device after confirmation and refetches the list', async () => { + mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE, IPHONE_DEVICE]) + const user = userEvent.setup() + renderSettings() + + await user.click(await screen.findByRole('button', { name: 'Revoke Safari on iPhone' })) + await user.click(await screen.findByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(mocks.revokePairedDevice).toHaveBeenCalledWith('session-iphone')) + await waitFor(() => expect(mocks.listPairedDevices).toHaveBeenCalledTimes(2)) + }) + + it('shows the error message when the revoke fails', async () => { + mocks.listPairedDevices.mockResolvedValue([CURRENT_DEVICE, IPHONE_DEVICE]) + mocks.revokePairedDevice.mockRejectedValueOnce(new Error('This device is no longer paired')) + const user = userEvent.setup() + renderSettings() + + await user.click(await screen.findByRole('button', { name: 'Revoke Safari on iPhone' })) + await user.click(await screen.findByRole('button', { name: 'Delete' })) + + expect(await screen.findByText('This device is no longer paired')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/PairedDevicesSettings.tsx b/frontend/src/components/settings/PairedDevicesSettings.tsx new file mode 100644 index 000000000..f8ee18950 --- /dev/null +++ b/frontend/src/components/settings/PairedDevicesSettings.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { formatDistanceToNow } from 'date-fns' +import { describeUserAgent } from '@opencode-manager/shared/utils' +import type { PairedDevice } from '@opencode-manager/shared/schemas' +import { usePairedDevices, useRevokePairedDevice } from '@/hooks/useDevicePairing' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { AlertCircle, Loader2, MonitorSmartphone, Trash2 } from 'lucide-react' + +function deviceLabel(device: PairedDevice): string { + return device.deviceName?.trim() || describeUserAgent(device.userAgent) +} + +export function PairedDevicesSettings() { + const { data: devices, isLoading } = usePairedDevices() + const revoke = useRevokePairedDevice() + const [deviceToRevoke, setDeviceToRevoke] = useState(null) + + const error = revoke.error instanceof Error + ? revoke.error.message + : revoke.error + ? 'Failed to revoke device' + : null + + const handleConfirmRevoke = () => { + if (!deviceToRevoke) return + revoke.mutate(deviceToRevoke.id) + setDeviceToRevoke(null) + } + + return ( + + + + + Paired devices + + + Every signed-in browser and app. Revoking one signs out only that device. + + + + {error && ( + + + {error} + + )} + + {isLoading ? ( +
+ +
+ ) : !devices || devices.length === 0 ? ( +

No paired devices.

+ ) : ( +
+ {devices.map((device) => { + const label = deviceLabel(device) + + return ( +
+
+
+

{label}

+ {device.isCurrent && ( + + This device + + )} +
+

+ {device.ipAddress ? `${device.ipAddress} · ` : null} + Signed in {formatDistanceToNow(new Date(device.createdAt), { addSuffix: true })} +

+
+ {!device.isCurrent && ( + + )} +
+ ) + })} +
+ )} +
+ + !open && setDeviceToRevoke(null)} + onConfirm={handleConfirmRevoke} + onCancel={() => setDeviceToRevoke(null)} + title="Revoke device" + description={deviceToRevoke ? `${deviceLabel(deviceToRevoke)} will be signed out.` : ''} + isDeleting={revoke.isPending} + /> +
+ ) +} diff --git a/frontend/src/components/settings/PairingQrCode.test.tsx b/frontend/src/components/settings/PairingQrCode.test.tsx new file mode 100644 index 000000000..d8839e976 --- /dev/null +++ b/frontend/src/components/settings/PairingQrCode.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PairingQrCode } from './PairingQrCode' + +describe('PairingQrCode', () => { + it('renders the QR code once uqr has loaded', async () => { + render() + + const image = await screen.findByAltText('Pairing QR code') + expect(image).toHaveAttribute('src', expect.stringContaining('data:image/svg+xml')) + }) + + it('reports values too large to encode', async () => { + render() + + expect( + await screen.findByText( + 'This QR code holds too much data. Remove some remote access headers.', + ), + ).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/settings/PairingQrCode.tsx b/frontend/src/components/settings/PairingQrCode.tsx new file mode 100644 index 000000000..f4cf6f23e --- /dev/null +++ b/frontend/src/components/settings/PairingQrCode.tsx @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useState } from 'react' +import { cn } from '@/lib/utils' + +type RenderSvg = (typeof import('uqr'))['renderSVG'] + +interface PairingQrCodeProps { + value: string + label: string + blurred?: boolean +} + +export function PairingQrCode({ value, label, blurred = false }: PairingQrCodeProps) { + const [renderSvg, setRenderSvg] = useState(null) + + useEffect(() => { + let active = true + void import('uqr').then((module) => { + if (active) setRenderSvg(() => module.renderSVG) + }) + return () => { + active = false + } + }, []) + + const source = useMemo(() => { + if (!renderSvg) return null + try { + return `data:image/svg+xml;utf8,${encodeURIComponent(renderSvg(value, { ecc: 'M', border: 2 }))}` + } catch { + return null + } + }, [renderSvg, value]) + + if (!renderSvg) return null + + if (!source) { + return ( +

+ This QR code holds too much data. Remove some remote access headers. +

+ ) + } + + return ( + {label} + ) +} diff --git a/frontend/src/components/settings/QrCodeScanner.tsx b/frontend/src/components/settings/QrCodeScanner.tsx index 8e52a4676..fee64855b 100644 --- a/frontend/src/components/settings/QrCodeScanner.tsx +++ b/frontend/src/components/settings/QrCodeScanner.tsx @@ -19,21 +19,24 @@ interface QrCodeScannerProps { open: boolean onOpenChange: (open: boolean) => void onResult: (text: string) => void + title?: string + description?: string + fallbackHint?: string } -function describeCameraError(error: unknown): string { +function describeCameraError(error: unknown, fallbackHint: string): string { if (error instanceof DOMException) { if (error.name === 'NotAllowedError' || error.name === 'SecurityError') { - return 'Camera permission was denied. Allow camera access for this site or paste the pairing link instead.' + return `Camera permission was denied. Allow camera access for this site. ${fallbackHint}` } if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') { - return 'No camera was found on this device. Paste the pairing link instead.' + return `No camera was found on this device. ${fallbackHint}` } if (error.name === 'NotReadableError') { - return 'The camera is already in use by another application. Paste the pairing link instead.' + return `The camera is already in use by another application. ${fallbackHint}` } } - return 'The camera could not be started. Paste the pairing link instead.' + return `The camera could not be started. ${fallbackHint}` } function fitScanDimensions(width: number, height: number): { width: number; height: number } { @@ -45,7 +48,14 @@ function fitScanDimensions(width: number, height: number): { width: number; heig return { width: Math.round(width * scale), height: Math.round(height * scale) } } -export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerProps) { +export function QrCodeScanner({ + open, + onOpenChange, + onResult, + title = 'Scan pairing QR code', + description = 'Point the camera at the QR code shown by the OpenCode server you want to link.', + fallbackHint = 'Paste the pairing link instead.', +}: QrCodeScannerProps) { const videoRef = useRef(null) const decoderRef = useRef(null) const onResultRef = useRef(onResult) @@ -126,12 +136,12 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro const startCamera = async () => { if (!navigator.mediaDevices?.getUserMedia) { - setError('Camera access is not available in this browser. Paste the pairing link instead.') + setError(`Camera access is not available in this browser. ${fallbackHint}`) setIsStarting(false) return } if (!window.isSecureContext) { - setError('Camera access requires a secure (HTTPS) connection. Paste the pairing link instead.') + setError(`Camera access requires a secure (HTTPS) connection. ${fallbackHint}`) setIsStarting(false) return } @@ -139,7 +149,7 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro await loadDecoder() } catch { if (cancelled) return - setError('The QR decoder could not be loaded. Paste the pairing link instead.') + setError(`The QR decoder could not be loaded. ${fallbackHint}`) setIsStarting(false) return } @@ -161,7 +171,7 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro frameId = requestAnimationFrame(scanFrame) } catch (cameraError) { if (cancelled) return - setError(describeCameraError(cameraError)) + setError(describeCameraError(cameraError, fallbackHint)) setIsStarting(false) } } @@ -172,16 +182,14 @@ export function QrCodeScanner({ open, onOpenChange, onResult }: QrCodeScannerPro cancelled = true stopCamera() } - }, [open, onOpenChange]) + }, [open, onOpenChange, fallbackHint]) return ( - Scan pairing QR code - - Point the camera at the QR code shown by the OpenCode server you want to link. - + {title} + {description}
diff --git a/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx b/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx new file mode 100644 index 000000000..4f47f40ec --- /dev/null +++ b/frontend/src/components/settings/RemoteAccessHeadersSettings.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReplaceRemoteAccessHeadersRequest } from '@opencode-manager/shared/schemas' +import { RemoteAccessHeadersSettings } from './RemoteAccessHeadersSettings' + +const mocks = vi.hoisted(() => ({ + getRemoteAccessHeaders: vi.fn(), + replaceRemoteAccessHeaders: vi.fn(), + clearRemoteAccessHeaders: vi.fn(), +})) + +vi.mock('@/api/devicePairing', () => ({ + getRemoteAccessHeaders: mocks.getRemoteAccessHeaders, + replaceRemoteAccessHeaders: mocks.replaceRemoteAccessHeaders, + clearRemoteAccessHeaders: mocks.clearRemoteAccessHeaders, +})) + +const STORED_VALUE_PLACEHOLDER = 'Stored – leave blank to keep' + +function renderSettings() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +describe('RemoteAccessHeadersSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: false }) + mocks.replaceRemoteAccessHeaders.mockImplementation( + async (body: ReplaceRemoteAccessHeadersRequest) => ({ + headers: body.headers.map((header) => ({ name: header.name, hasValue: true })), + unreadable: false, + }), + ) + mocks.clearRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: false }) + }) + + it('adds the Cloudflare service token rows and saves typed values only once', async () => { + const user = userEvent.setup() + renderSettings() + + await user.click( + await screen.findByRole('button', { name: /Cloudflare Access service token/i }), + ) + + const nameInputs = screen.getAllByPlaceholderText('Header name') + expect(nameInputs.map((input) => (input as HTMLInputElement).value)).toEqual([ + 'CF-Access-Client-Id', + 'CF-Access-Client-Secret', + ]) + + const valueInputs = screen.getAllByPlaceholderText('Value') + await user.type(valueInputs[0] as HTMLInputElement, 'id') + await user.type(valueInputs[1] as HTMLInputElement, 'secret') + + await user.click(screen.getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({ + headers: [ + { name: 'CF-Access-Client-Id', value: 'id' }, + { name: 'CF-Access-Client-Secret', value: 'secret' }, + ], + }) + }) + + await waitFor(() => { + expect(screen.queryByDisplayValue('secret')).not.toBeInTheDocument() + }) + expect(await screen.findAllByPlaceholderText(STORED_VALUE_PLACEHOLDER)).toHaveLength(2) + }) + + it('keeps a stored value when its blank field is left untouched', async () => { + mocks.getRemoteAccessHeaders.mockResolvedValue({ + headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }], + unreadable: false, + }) + const user = userEvent.setup() + renderSettings() + + const valueInput = await screen.findByPlaceholderText(STORED_VALUE_PLACEHOLDER) + expect(valueInput).toHaveValue('') + + await user.click(screen.getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({ + headers: [{ name: 'CF-Access-Client-Secret', value: undefined }], + }) + }) + }) + + it('shows the schema error for a reserved header name and blocks saving', async () => { + const user = userEvent.setup() + renderSettings() + + await user.click(await screen.findByRole('button', { name: /Add header/i })) + await user.type(screen.getByPlaceholderText('Header name'), 'Cookie') + await user.type(screen.getByPlaceholderText('Value'), 'session') + + expect(await screen.findByText('This header name is reserved')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /^Save$/i })) + + expect(mocks.replaceRemoteAccessHeaders).not.toHaveBeenCalled() + }) + + it('requires a value after renaming a stored row to a name the server does not have', async () => { + mocks.getRemoteAccessHeaders.mockResolvedValue({ + headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }], + unreadable: false, + }) + const user = userEvent.setup() + renderSettings() + + expect(await screen.findByPlaceholderText(STORED_VALUE_PLACEHOLDER)).toBeInTheDocument() + + const nameInput = screen.getByPlaceholderText('Header name') + await user.clear(nameInput) + await user.type(nameInput, 'X-Custom-Secret') + + expect(screen.getByPlaceholderText('Value')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled() + + await user.type(screen.getByPlaceholderText('Value'), 'new-secret') + await user.click(screen.getByRole('button', { name: /^Save$/i })) + + await waitFor(() => { + expect(mocks.replaceRemoteAccessHeaders).toHaveBeenCalledWith({ + headers: [{ name: 'X-Custom-Secret', value: 'new-secret' }], + }) + }) + }) + + it('shows the decrypt alert when the stored headers are unreadable', async () => { + mocks.getRemoteAccessHeaders.mockResolvedValue({ headers: [], unreadable: true }) + renderSettings() + + expect( + await screen.findByText( + 'Stored headers could not be decrypted (AUTH_SECRET may have changed). Enter them again.', + ), + ).toBeInTheDocument() + }) + + it('clears all headers after confirmation', async () => { + mocks.getRemoteAccessHeaders.mockResolvedValue({ + headers: [{ name: 'CF-Access-Client-Secret', hasValue: true }], + unreadable: false, + }) + const user = userEvent.setup() + renderSettings() + + await user.click(await screen.findByRole('button', { name: /Clear all/i })) + await user.click(await screen.findByRole('button', { name: /^Delete$/i })) + + await waitFor(() => { + expect(mocks.clearRemoteAccessHeaders).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx b/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx new file mode 100644 index 000000000..bd865b770 --- /dev/null +++ b/frontend/src/components/settings/RemoteAccessHeadersSettings.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState } from 'react' +import { + CLOUDFLARE_ACCESS_HEADER_NAMES, + REMOTE_ACCESS_HEADER_MAX_COUNT, + RemoteAccessHeaderNameSchema, + RemoteAccessHeaderValueSchema, + type RemoteAccessHeaderSummary, +} from '@opencode-manager/shared/schemas' +import { useRemoteAccessHeaders } from '@/hooks/useDevicePairing' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { AlertCircle, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react' + +const STORED_VALUE_PLACEHOLDER = 'Stored – leave blank to keep' + +interface HeaderRow { + id: number + name: string + value: string +} + +let nextRowIdValue = 0 + +function nextRowId(): number { + nextRowIdValue += 1 + return nextRowIdValue +} + +function createRows(summaries: RemoteAccessHeaderSummary[]): HeaderRow[] { + return summaries.map((header) => ({ + id: nextRowId(), + name: header.name, + value: '', + })) +} + +function normalizeName(name: string): string { + return name.trim().toLowerCase() +} + +function hasStoredValue(storedNames: Set, name: string): boolean { + return storedNames.has(normalizeName(name)) +} + +function rowNameError(row: HeaderRow): string | null { + const parsed = RemoteAccessHeaderNameSchema.safeParse(row.name) + return parsed.success ? null : parsed.error.issues[0]?.message ?? 'Invalid header name' +} + +function rowValueError(row: HeaderRow, stored: boolean): string | null { + if (stored && row.value.trim() === '') return null + const parsed = RemoteAccessHeaderValueSchema.safeParse(row.value) + return parsed.success ? null : parsed.error.issues[0]?.message ?? 'Invalid header value' +} + +function isRowValid(row: HeaderRow, stored: boolean): boolean { + return RemoteAccessHeaderNameSchema.safeParse(row.name).success && rowValueError(row, stored) === null +} + +export function RemoteAccessHeadersSettings() { + const { headers, unreadable, isLoading, replace, clear, isSaving } = useRemoteAccessHeaders() + const [rows, setRows] = useState(() => (isLoading ? [] : createRows(headers))) + const [error, setError] = useState(null) + const [confirmClear, setConfirmClear] = useState(false) + const initialized = useRef(!isLoading) + + useEffect(() => { + if (initialized.current || isLoading) return + initialized.current = true + setRows(createRows(headers)) + }, [headers, isLoading]) + + const storedNames = new Set(headers.map((header) => normalizeName(header.name))) + const rowsAreValid = rows.every((row) => isRowValid(row, hasStoredValue(storedNames, row.name))) + const atMaxCount = rows.length >= REMOTE_ACCESS_HEADER_MAX_COUNT + + const handleAdd = () => { + setRows((current) => [...current, { id: nextRowId(), name: '', value: '' }]) + } + + const handleCloudflarePreset = () => { + setRows((current) => { + const existingNames = new Set(current.map((row) => normalizeName(row.name))) + const missingNames = CLOUDFLARE_ACCESS_HEADER_NAMES.filter( + (name) => !existingNames.has(normalizeName(name)), + ) + const availableSlots = REMOTE_ACCESS_HEADER_MAX_COUNT - current.length + return [ + ...current, + ...missingNames.slice(0, Math.max(availableSlots, 0)).map((name) => ({ + id: nextRowId(), + name, + value: '', + })), + ] + }) + } + + const handleRemove = (index: number) => { + setRows((current) => current.filter((_, rowIndex) => rowIndex !== index)) + } + + const handleNameChange = (index: number, name: string) => { + setRows((current) => + current.map((row, rowIndex) => (rowIndex === index ? { ...row, name } : row)), + ) + } + + const handleValueChange = (index: number, value: string) => { + setRows((current) => + current.map((row, rowIndex) => (rowIndex === index ? { ...row, value } : row)), + ) + } + + const handleSave = async () => { + setError(null) + if (!rowsAreValid) return + + try { + const response = await replace.mutateAsync({ + headers: rows.map((row) => ({ + name: row.name.trim(), + value: row.value.trim() === '' ? undefined : row.value.trim(), + })), + }) + setRows(createRows(response.headers)) + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Failed to save remote access headers') + } + } + + const handleClear = async () => { + setError(null) + setConfirmClear(false) + + try { + const response = await clear.mutateAsync() + setRows(createRows(response.headers)) + } catch (clearError) { + setError(clearError instanceof Error ? clearError.message : 'Failed to clear remote access headers') + } + } + + return ( + + + + + Remote access headers + + + Sent by the mobile app on every request, so it can pass a reverse proxy such as Cloudflare Access. + + + + {unreadable && ( + + + + Stored headers could not be decrypted (AUTH_SECRET may have changed). Enter them again. + + + )} + + {error && ( + + + {error} + + )} + + {isLoading ? ( +
+ +
+ ) : ( + <> + {rows.length === 0 ? ( +

+ No remote access headers are stored. +

+ ) : ( +
+ {rows.map((row, index) => { + const stored = hasStoredValue(storedNames, row.name) + const nameError = row.name.trim() === '' ? null : rowNameError(row) + const valueError = row.name.trim() === '' ? null : rowValueError(row, stored) + + return ( +
+
+
+ handleNameChange(index, event.target.value)} + placeholder="Header name" + aria-label={`Header ${index + 1} name`} + className="h-9 font-mono md:text-sm" + disabled={isSaving} + /> + {nameError &&

{nameError}

} +
+
+ handleValueChange(index, event.target.value)} + placeholder={stored ? STORED_VALUE_PLACEHOLDER : 'Value'} + aria-label={`Header ${index + 1} value`} + className="h-9 font-mono md:text-sm" + disabled={isSaving} + /> + {valueError &&

{valueError}

} +
+ +
+
+ ) + })} +
+ )} + +
+ + + + +
+ + )} + +

+ Cloudflare Access: create a service token in Zero Trust, add a Service Auth policy for it + on the Manager's Access application, then paste its Client ID and Client Secret here. +

+
+ + !open && setConfirmClear(false)} + onConfirm={handleClear} + onCancel={() => setConfirmClear(false)} + title="Clear Remote Access Headers" + description="The mobile app will stop sending these headers on every request." + isDeleting={clear.isPending} + /> +
+ ) +} diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 5f9c5f0cc..c7110eee9 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -129,7 +129,7 @@ export function SettingsDialog() { }, [isOpen, isDesktop, selectedTab, pushSectionHistory]) const menuItems: Array<{ id: SettingsContentTab; icon: LucideIcon; label: string; description: string }> = [ - { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, and sign out' }, + { id: 'account', icon: User, label: 'Account', description: 'Profile, passkeys, devices, and sign out' }, { id: 'general', icon: Settings2, label: 'General Settings', description: 'App preferences and behavior' }, { id: 'notifications', icon: Bell, label: 'Notifications', description: 'Push notification preferences' }, { id: 'voice', icon: Volume2, label: 'Voice', description: 'Text-to-speech and speech-to-text settings' }, diff --git a/frontend/src/contexts/AuthContext.test.tsx b/frontend/src/contexts/AuthContext.test.tsx index d25fc2766..cb9f86756 100644 --- a/frontend/src/contexts/AuthContext.test.tsx +++ b/frontend/src/contexts/AuthContext.test.tsx @@ -1,7 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { act, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useState } from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query' import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { describeUserAgent } from '@opencode-manager/shared/utils' +import { PAIRING_CODE_INVALID_MESSAGE } from '@opencode-manager/shared/schemas' +import { FetchError } from '@/api/fetchWrapper' import { LocationCatcher } from '@/test/test-utils' import { AuthProvider } from './AuthContext' import { QueryCachePersistenceProvider } from './QueryCachePersistenceContext' @@ -17,6 +22,7 @@ const mocks = vi.hoisted(() => ({ signInPasskey: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), + redeemDevicePairingCode: vi.fn(), })) vi.mock('@/lib/auth-client', () => ({ @@ -24,12 +30,17 @@ vi.mock('@/lib/auth-client', () => ({ signIn: { email: mocks.signInEmail, social: mocks.signInSocial }, signUp: { email: mocks.signUpEmail }, signOut: mocks.signOut, - authClient: { signIn: { passkey: mocks.signInPasskey } }, + authClient: { signIn: { passkey: mocks.signInPasskey }, passkey: { addPasskey: vi.fn() } }, +})) + +vi.mock('@/api/devicePairing', () => ({ + redeemDevicePairingCode: mocks.redeemDevicePairingCode, })) type AuthValue = ReturnType const STORED_USER = { id: 'user-1', email: 'user@example.com', name: 'Ada' } +const pairingCode = 'a'.repeat(43) function renderAuth(initialEntry: string, options: { persistence?: boolean } = {}) { const capturedPathname: { current: string } = { current: '' } @@ -71,6 +82,48 @@ function AuthProbe({ captured }: { captured: { current: AuthValue | null } }) { return null } +function PairingSignIn() { + const { signInWithPairingCode } = useAuth() + const [error, setError] = useState(null) + + const submit = async () => { + const result = await signInWithPairingCode(pairingCode) + setError(result.error ?? null) + } + + return ( + <> + + {error &&

{error}

} + + ) +} + +function renderPairing(initialEntry: string) { + return render( + + + + } /> + repos marker
} /> + home marker
} /> + + + , + ) +} + +async function pair() { + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Pair' })) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('AuthProvider post-login navigation', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,6 +194,68 @@ describe('AuthProvider post-login navigation', () => { }) }) +describe('AuthProvider signInWithPairingCode', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.useSession.mockReturnValue({ data: null, error: null, isPending: false, refetch: mocks.refetch }) + mocks.refetch.mockResolvedValue(undefined) + mocks.redeemDevicePairingCode.mockResolvedValue({ user: { id: 'u1', name: 'Ada', email: 'ada@example.com' } }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ enabledProviders: ['credentials'], registrationEnabled: true, isFirstUser: false, adminConfigured: true }), + })) + }) + + it('redeems the code with the described device and returns to the redirect target', async () => { + renderPairing('/login?redirect=%2Frepos%2F1') + + await pair() + + expect(await screen.findByText('repos marker')).toBeInTheDocument() + expect(mocks.redeemDevicePairingCode).toHaveBeenCalledWith({ + code: pairingCode, + deviceName: describeUserAgent(navigator.userAgent), + }) + expect(mocks.refetch).toHaveBeenCalledTimes(1) + }) + + it('lands home without a redirect target', async () => { + renderPairing('/login') + + await pair() + + expect(await screen.findByText('home marker')).toBeInTheDocument() + }) + + it('shows the server message when the code expired or was already used', async () => { + mocks.redeemDevicePairingCode.mockRejectedValue( + new FetchError(PAIRING_CODE_INVALID_MESSAGE, 400, 'PAIRING_CODE_INVALID'), + ) + renderPairing('/login?redirect=%2Frepos%2F1') + + await pair() + + expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument() + expect(screen.queryByText('repos marker')).not.toBeInTheDocument() + expect(mocks.refetch).not.toHaveBeenCalled() + }) + + it('shows the rate-limit message for a 429', async () => { + mocks.redeemDevicePairingCode.mockRejectedValue( + new FetchError('Too many attempts', 429, 'RATE_LIMITED'), + ) + renderPairing('/login') + + await pair() + + expect( + await screen.findByText('Too many attempts. Wait a minute and try again.'), + ).toBeInTheDocument() + expect(mocks.refetch).not.toHaveBeenCalled() + }) +}) + describe('AuthProvider session restore', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 0d9df3c64..41f85b1fe 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,9 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useEffect, useMemo, useState, useCallback, type ReactNode } from 'react' +import { describeUserAgent } from '@opencode-manager/shared/utils' import { useSession, signIn, signUp, signOut, authClient } from '@/lib/auth-client' +import { redeemDevicePairingCode } from '@/api/devicePairing' +import { FetchError } from '@/api/fetchWrapper' import { useNavigate, useLocation } from 'react-router-dom' import { getLoginRedirectTarget } from '@/lib/auth-loaders' import { @@ -11,6 +14,9 @@ import { type StoredAuthUser, } from '@/lib/authSession' +const RATE_LIMITED_MESSAGE = 'Too many attempts. Wait a minute and try again.' +const SIGN_IN_FAILED_MESSAGE = 'Sign-in failed' + interface AuthConfig { enabledProviders: string[] registrationEnabled: boolean @@ -26,6 +32,7 @@ interface AuthContextValue { signInWithEmail: (email: string, password: string) => Promise<{ error?: string }> signInWithProvider: (provider: 'github' | 'google' | 'discord') => Promise<{ error?: string }> signInWithPasskey: () => Promise<{ error?: string }> + signInWithPairingCode: (code: string) => Promise<{ error?: string }> signUpWithEmail: (email: string, password: string, name: string) => Promise<{ error?: string }> addPasskey: (name?: string) => Promise<{ error?: string }> logout: () => Promise @@ -112,6 +119,23 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, [refetch, navigate, redirectTarget]) + const signInWithPairingCode = useCallback(async (code: string) => { + try { + await redeemDevicePairingCode({ + code, + deviceName: describeUserAgent(navigator.userAgent), + }) + await refetch() + } catch (err) { + if (err instanceof FetchError && err.statusCode === 429) { + return { error: RATE_LIMITED_MESSAGE } + } + return { error: err instanceof Error && err.message ? err.message : SIGN_IN_FAILED_MESSAGE } + } + navigate(redirectTarget, { replace: true }) + return {} + }, [refetch, navigate, redirectTarget]) + const signUpWithEmail = useCallback(async (email: string, password: string, name: string) => { const result = await signUp.email({ email, password, name }) if (result.error) { @@ -175,6 +199,7 @@ export function AuthProvider({ children }: AuthProviderProps) { signInWithEmail, signInWithProvider, signInWithPasskey, + signInWithPairingCode, signUpWithEmail, addPasskey, logout, @@ -186,6 +211,7 @@ export function AuthProvider({ children }: AuthProviderProps) { signInWithEmail, signInWithProvider, signInWithPasskey, + signInWithPairingCode, signUpWithEmail, addPasskey, logout, diff --git a/frontend/src/hooks/useDevicePairing.ts b/frontend/src/hooks/useDevicePairing.ts new file mode 100644 index 000000000..a3d01ed93 --- /dev/null +++ b/frontend/src/hooks/useDevicePairing.ts @@ -0,0 +1,81 @@ +import { useCallback } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { + CreateDevicePairingLinksRequest, + RemoteAccessHeadersResponse, + ReplaceRemoteAccessHeadersRequest, +} from '@opencode-manager/shared/schemas' +import { + clearRemoteAccessHeaders, + createDevicePairingLinks, + getRemoteAccessHeaders, + listPairedDevices, + replaceRemoteAccessHeaders, + revokePairedDevice, +} from '@/api/devicePairing' + +const REMOTE_ACCESS_HEADERS_QUERY_KEY = ['remote-access-headers'] +const PAIRED_DEVICES_QUERY_KEY = ['paired-devices'] + +export function useRemoteAccessHeaders() { + const queryClient = useQueryClient() + + const query = useQuery({ + queryKey: REMOTE_ACCESS_HEADERS_QUERY_KEY, + queryFn: getRemoteAccessHeaders, + }) + + const setHeaders = (response: RemoteAccessHeadersResponse) => { + queryClient.setQueryData(REMOTE_ACCESS_HEADERS_QUERY_KEY, response) + } + + const replace = useMutation({ + mutationFn: (body: ReplaceRemoteAccessHeadersRequest) => replaceRemoteAccessHeaders(body), + onSuccess: setHeaders, + }) + + const clear = useMutation({ + mutationFn: clearRemoteAccessHeaders, + onSuccess: setHeaders, + }) + + return { + headers: query.data?.headers ?? [], + unreadable: query.data?.unreadable ?? false, + isLoading: query.isLoading, + replace, + clear, + isSaving: replace.isPending || clear.isPending, + } +} + +export function useCreateDevicePairingLinks() { + return useMutation({ + mutationFn: (body: CreateDevicePairingLinksRequest = {}) => createDevicePairingLinks(body), + }) +} + +export function usePairedDevices() { + return useQuery({ + queryKey: PAIRED_DEVICES_QUERY_KEY, + queryFn: listPairedDevices, + }) +} + +export function useInvalidatePairedDevices() { + const queryClient = useQueryClient() + + return useCallback( + () => queryClient.invalidateQueries({ queryKey: PAIRED_DEVICES_QUERY_KEY }), + [queryClient], + ) +} + +export function useRevokePairedDevice() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: string) => revokePairedDevice(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: PAIRED_DEVICES_QUERY_KEY }), + }) +} diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts index e1a5050d9..03bdc307e 100644 --- a/frontend/src/lib/auth-client.ts +++ b/frontend/src/lib/auth-client.ts @@ -2,7 +2,7 @@ import { createAuthClient } from 'better-auth/react' import { passkeyClient } from '@better-auth/passkey/client' import { API_BASE_URL } from '@/config' -const getBaseUrl = () => { +export const getAuthBaseUrl = (): string => { if (typeof window !== 'undefined') { return `${window.location.origin}/api/auth` } @@ -10,7 +10,7 @@ const getBaseUrl = () => { } export const authClient = createAuthClient({ - baseURL: getBaseUrl(), + baseURL: getAuthBaseUrl(), plugins: [passkeyClient()], }) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 32066716f..6913b419c 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,13 +3,15 @@ import { useLoaderData } from 'react-router-dom' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' +import { parseManagerBrowserPairingLink } from '@opencode-manager/shared/schemas' import { useAuth } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' +import { QrCodeScanner } from '@/components/settings/QrCodeScanner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Alert, AlertDescription } from '@/components/ui/alert' -import { Loader2, Github, KeyRound, Mail, AlertCircle } from 'lucide-react' +import { Loader2, Github, KeyRound, Mail, AlertCircle, ScanLine } from 'lucide-react' import type { AuthConfig } from '@/lib/auth-loaders' const loginSchema = z.object({ @@ -20,13 +22,15 @@ const loginSchema = z.object({ type LoginFormData = z.infer export function Login() { - const { signInWithEmail, signInWithProvider, signInWithPasskey } = useAuth() + const { signInWithEmail, signInWithProvider, signInWithPasskey, signInWithPairingCode } = useAuth() const { config } = useLoaderData() as { config: AuthConfig } const theme = useTheme() const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [isPasskeyLoading, setIsPasskeyLoading] = useState(false) const [oauthLoading, setOauthLoading] = useState(null) + const [isScannerOpen, setIsScannerOpen] = useState(false) + const [isRedeeming, setIsRedeeming] = useState(false) const { register, @@ -75,6 +79,28 @@ export function Login() { } } + const handleScan = async (text: string) => { + setError(null) + const link = parseManagerBrowserPairingLink(text) + if (!link) { + setError('That QR code is not a Manager sign-in code.') + return + } + if (link.origin !== window.location.origin) { + setError(`This QR code is for ${link.origin}. Open that address to sign in.`) + return + } + setIsRedeeming(true) + try { + const result = await signInWithPairingCode(link.code) + if (result.error) { + setError(result.error) + } + } finally { + setIsRedeeming(false) + } + } + const hasOAuth = config.enabledProviders.some(p => ['github', 'google', 'discord'].includes(p)) const hasPasskey = config.enabledProviders.includes('passkey') const hasCredentials = config.enabledProviders.includes('credentials') @@ -98,6 +124,16 @@ export function Login() { )} + + {hasPasskey && (
) diff --git a/frontend/src/pages/Pair.tsx b/frontend/src/pages/Pair.tsx new file mode 100644 index 000000000..f810f040e --- /dev/null +++ b/frontend/src/pages/Pair.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { AlertCircle, Loader2 } from 'lucide-react' +import { showToast } from '@/lib/toast' +import { readPairingCodeFromHash } from '@opencode-manager/shared/schemas' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { useAuth } from '@/hooks/useAuth' +import { useTheme } from '@/hooks/useTheme' +import { getSession } from '@/lib/auth-client' + +type PairState = 'redeeming' | 'missing' | 'error' + +/** Signs the browser in from a `/pair#c=` link. */ +export function Pair() { + const navigate = useNavigate() + const theme = useTheme() + const { signInWithPairingCode } = useAuth() + const [code] = useState(() => readPairingCodeFromHash(window.location.hash)) + const [state, setState] = useState('redeeming') + const [errorMessage, setErrorMessage] = useState(null) + const startedRef = useRef(false) + + useEffect(() => { + if (!window.location.hash) return + window.history.replaceState( + window.history.state, + '', + window.location.pathname + window.location.search, + ) + }, []) + + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + + const run = async () => { + if (!code) { + setState('missing') + return + } + + const session = await getSession() + if (session?.data?.user) { + showToast.info('Already signed in') + navigate('/', { replace: true }) + return + } + + const result = await signInWithPairingCode(code) + if (result.error) { + setErrorMessage(result.error) + setState('error') + } + } + + void run().catch((error: unknown) => { + setErrorMessage(error instanceof Error && error.message ? error.message : 'Sign-in failed') + setState('error') + }) + }, [code, navigate, signInWithPairingCode]) + + return ( +
+
+
+ OpenCode Manager +
+ +
+ {state === 'redeeming' && ( +
+ +

Signing you in…

+
+ )} + + {state === 'missing' && ( + <> + + + No pairing code was found + + + + )} + + {state === 'error' && ( + <> + + + {errorMessage} + + + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx new file mode 100644 index 000000000..617a8190f --- /dev/null +++ b/frontend/src/pages/__tests__/Login.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { + buildManagerBrowserPairingLink, + PAIRING_CODE_INVALID_MESSAGE, +} from '@opencode-manager/shared/schemas' +import { Login } from '../Login' + +const mocks = vi.hoisted(() => ({ + signInWithEmail: vi.fn(), + signInWithProvider: vi.fn(), + signInWithPasskey: vi.fn(), + signInWithPairingCode: vi.fn(), + scanText: { value: '' }, +})) + +vi.mock('react-router-dom', () => ({ + useLoaderData: () => ({ + config: { + enabledProviders: ['credentials', 'passkey'], + registrationEnabled: true, + isFirstUser: false, + adminConfigured: false, + }, + }), +})) + +vi.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ + signInWithEmail: mocks.signInWithEmail, + signInWithProvider: mocks.signInWithProvider, + signInWithPasskey: mocks.signInWithPasskey, + signInWithPairingCode: mocks.signInWithPairingCode, + }), +})) + +vi.mock('@/hooks/useTheme', () => ({ + useTheme: () => 'light', +})) + +vi.mock('@/components/settings/QrCodeScanner', () => ({ + QrCodeScanner: ({ open, onResult }: { open: boolean; onResult: (text: string) => void }) => + open ? ( + + ) : null, +})) + +const code = 'a'.repeat(43) + +function renderLogin() { + return render() +} + +async function scan(text: string) { + const user = userEvent.setup() + mocks.scanText.value = text + await user.click(screen.getByRole('button', { name: 'Sign in with QR code' })) + await user.click(await screen.findByRole('button', { name: 'Deliver scanned text' })) +} + +describe('Login', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.scanText.value = '' + mocks.signInWithPairingCode.mockResolvedValue({}) + }) + + it('starts a QR scan from the sign-in button', async () => { + const user = userEvent.setup() + renderLogin() + + const button = screen.getByRole('button', { name: 'Sign in with QR code' }) + expect(button).toBeEnabled() + expect(screen.queryByRole('button', { name: 'Deliver scanned text' })).not.toBeInTheDocument() + + await user.click(button) + + expect(await screen.findByRole('button', { name: 'Deliver scanned text' })).toBeInTheDocument() + }) + + it('redeems the code from a manager sign-in QR code', async () => { + renderLogin() + + await scan(buildManagerBrowserPairingLink(window.location.origin, code)) + + await waitFor(() => { + expect(mocks.signInWithPairingCode).toHaveBeenCalledTimes(1) + }) + expect(mocks.signInWithPairingCode).toHaveBeenCalledWith(code) + }) + + it('rejects a QR code for another origin', async () => { + renderLogin() + + await scan(buildManagerBrowserPairingLink('https://other.example.com', code)) + + expect( + await screen.findByText( + 'This QR code is for https://other.example.com. Open that address to sign in.', + ), + ).toBeInTheDocument() + expect(mocks.signInWithPairingCode).not.toHaveBeenCalled() + }) + + it.each(['ocm://link?v=2&kind=manager&name=Desk', 'not a pairing link'])( + 'rejects text that is not a manager sign-in code: %s', + async (text) => { + renderLogin() + + await scan(text) + + expect( + await screen.findByText('That QR code is not a Manager sign-in code.'), + ).toBeInTheDocument() + expect(mocks.signInWithPairingCode).not.toHaveBeenCalled() + }, + ) + + it('shows the redeem error in the page alert', async () => { + mocks.signInWithPairingCode.mockResolvedValue({ error: PAIRING_CODE_INVALID_MESSAGE }) + renderLogin() + + await scan(buildManagerBrowserPairingLink(window.location.origin, code)) + + expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/Pair.test.tsx b/frontend/src/pages/__tests__/Pair.test.tsx new file mode 100644 index 000000000..352dc58b4 --- /dev/null +++ b/frontend/src/pages/__tests__/Pair.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StrictMode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { PAIRING_CODE_INVALID_MESSAGE } from '@opencode-manager/shared/schemas' +import { Pair } from '../Pair' + +const mocks = vi.hoisted(() => ({ + signInWithPairingCode: vi.fn(), + getSession: vi.fn(), +})) + +vi.mock('@/lib/auth-client', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/hooks/useAuth', async () => { + const { useNavigate } = await import('react-router-dom') + return { + useAuth: () => { + const navigate = useNavigate() + return { + signInWithPairingCode: async (code: string) => { + const result = await mocks.signInWithPairingCode(code) + if (!result.error) navigate('/', { replace: true }) + return result + }, + } + }, + } +}) + +const code = 'a'.repeat(43) + +function renderPair() { + return render( + + + + } /> + home marker} /> + + + , + ) +} + +describe('Pair', () => { + beforeEach(() => { + vi.clearAllMocks() + window.history.replaceState(null, '', '/pair') + mocks.getSession.mockResolvedValue({ data: null }) + mocks.signInWithPairingCode.mockResolvedValue({}) + }) + + it('redeems once with the hash cleared first and lands home', async () => { + window.history.replaceState(null, '', '/pair#c=' + code) + mocks.signInWithPairingCode.mockImplementation(async () => { + expect(window.location.hash).toBe('') + return {} + }) + + renderPair() + + await screen.findByText('home marker') + expect(window.location.hash).toBe('') + expect(mocks.signInWithPairingCode).toHaveBeenCalledTimes(1) + expect(mocks.signInWithPairingCode).toHaveBeenCalledWith(code) + expect(mocks.getSession).toHaveBeenCalled() + }) + + it('keeps an existing session without redeeming', async () => { + mocks.getSession.mockResolvedValue({ data: { user: { id: 'u1' } } }) + window.history.replaceState(null, '', '/pair#c=' + code) + + renderPair() + + await screen.findByText('home marker') + expect(mocks.signInWithPairingCode).not.toHaveBeenCalled() + expect(window.location.hash).toBe('') + }) + + it('shows the message and a sign-in link for an expired or used code', async () => { + window.history.replaceState(null, '', '/pair#c=' + code) + mocks.signInWithPairingCode.mockResolvedValue({ error: PAIRING_CODE_INVALID_MESSAGE }) + + renderPair() + + expect(await screen.findByText(PAIRING_CODE_INVALID_MESSAGE)).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login') + }) + }) + + it('shows the rate-limit message for a 429', async () => { + window.history.replaceState(null, '', '/pair#c=' + code) + mocks.signInWithPairingCode.mockResolvedValue({ + error: 'Too many attempts. Wait a minute and try again.', + }) + + renderPair() + + expect( + await screen.findByText('Too many attempts. Wait a minute and try again.'), + ).toBeInTheDocument() + }) + + it('reports a missing code', async () => { + renderPair() + + expect(await screen.findByText('No pairing code was found')).toBeInTheDocument() + expect(mocks.signInWithPairingCode).not.toHaveBeenCalled() + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/login') + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 358210671..4937d0039 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.4.0 + uqr: + specifier: ^0.1.3 + version: 0.1.3 zod: specifier: ^4.1.12 version: 4.3.2 diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index b8f647635..be8d9dcdf 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -176,6 +176,17 @@ export const getTrustedOrigins = (): string[] => .map((origin) => origin.trim()) .filter((origin) => origin.length > 0) +/** Reports whether a request Origin is one of the configured trusted origins. */ +export const isTrustedOrigin = ( + origin: string | undefined, + options: { requireOrigin?: boolean } = {}, + trustedOrigins: readonly string[] = getTrustedOrigins(), +): boolean => { + if (!origin) return !(options.requireOrigin ?? false) + if (origin === 'null') return false + return trustedOrigins.includes(origin) +} + export const getApiUrl = (port: number = ENV.SERVER.PORT): string => { const host = ENV.SERVER.HOST diff --git a/shared/src/schemas/device-pairing.ts b/shared/src/schemas/device-pairing.ts new file mode 100644 index 000000000..a67676afa --- /dev/null +++ b/shared/src/schemas/device-pairing.ts @@ -0,0 +1,204 @@ +import { z } from "zod"; +import { trimUrlTrailingSlashes } from "../utils/url"; + +export const REMOTE_ACCESS_HEADER_MAX_COUNT = 8; +export const REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH = 512; +export const REMOTE_ACCESS_HEADER_NAME_MAX_LENGTH = 128; +export const REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH = 1800; +export const CLOUDFLARE_ACCESS_HEADER_NAMES = ["CF-Access-Client-Id", "CF-Access-Client-Secret"] as const; +export const DEVICE_PAIRING_PATH = "/pair"; +export const DEVICE_PAIRING_CODE_TTL_SECONDS = 180; + +export const DEVICE_PAIRING_ERROR_CODES = { + PAIRING_CODE_INVALID: "PAIRING_CODE_INVALID", + DEVICE_NOT_FOUND: "DEVICE_NOT_FOUND", + CANNOT_REVOKE_CURRENT_SESSION: "CANNOT_REVOKE_CURRENT_SESSION", +} as const; + +export const PAIRING_CODE_INVALID_MESSAGE = + "This pairing code has expired or was already used. Show a new QR code and scan again."; + +const HTTP_HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const BLOCKED_REMOTE_ACCESS_HEADER_NAMES = new Set([ + "cookie", + "set-cookie", + "host", + "origin", + "content-length", + "content-type", + "connection", + "transfer-encoding", + "upgrade", + "te", + "keep-alive", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "forwarded", + "x-real-ip", + "cf-connecting-ip", +]); + +/** Reports whether a header name is reserved by the transport or uses the proxy- prefix. */ +export function isBlockedRemoteAccessHeaderName(name: string): boolean { + const normalized = name.trim().toLowerCase(); + return BLOCKED_REMOTE_ACCESS_HEADER_NAMES.has(normalized) || normalized.startsWith("proxy-"); +} + +export const RemoteAccessHeaderNameSchema = z + .string() + .trim() + .min(1) + .max(REMOTE_ACCESS_HEADER_NAME_MAX_LENGTH) + .regex(HTTP_HEADER_NAME_TOKEN, "Header names must be valid HTTP tokens") + .refine((name) => !isBlockedRemoteAccessHeaderName(name), "This header name is reserved"); + +const remoteAccessHeaderValueRules = z + .string() + .refine((value) => !/[\r\n\0]/.test(value), "Header values must not contain line breaks") + .trim() + .max(REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH); + +export const RemoteAccessHeaderValueSchema = remoteAccessHeaderValueRules.min(1); + +export const RemoteAccessHeaderDraftValueSchema = remoteAccessHeaderValueRules; + +export const RemoteAccessHeaderSchema = z.object({ + name: RemoteAccessHeaderNameSchema, + value: RemoteAccessHeaderValueSchema, +}); + +export const RemoteAccessHeaderListSchema = z + .array(RemoteAccessHeaderSchema) + .max(REMOTE_ACCESS_HEADER_MAX_COUNT) + .refine( + (headers) => new Set(headers.map((header) => header.name.toLowerCase())).size === headers.length, + "Header names must be unique", + ) + .refine( + (headers) => encodedRemoteAccessHeadersLength(headers) <= REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH, + `Remote access headers are too long to fit in a QR code (limit ${REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH} encoded characters)`, + ); + +/** Serializes a remote-access header into the `Name:Value` form carried by the link `h` parameter. */ +export function formatRemoteAccessHeaderParam(header: RemoteAccessHeader): string { + return `${header.name}:${header.value}`; +} + +function encodedRemoteAccessHeadersLength(headers: RemoteAccessHeader[]): number { + const params = new URLSearchParams(); + for (const header of headers) params.append("h", formatRemoteAccessHeaderParam(header)); + return params.toString().length; +} + +export const RemoteAccessHeaderSummarySchema = z.object({ + name: z.string(), + hasValue: z.boolean(), +}); + +export const RemoteAccessHeadersResponseSchema = z.object({ + headers: z.array(RemoteAccessHeaderSummarySchema), + unreadable: z.boolean(), +}); + +export const ReplaceRemoteAccessHeadersRequestSchema = z.object({ + headers: z + .array( + z.object({ + name: RemoteAccessHeaderNameSchema, + value: RemoteAccessHeaderDraftValueSchema.optional(), + }), + ) + .max(REMOTE_ACCESS_HEADER_MAX_COUNT), +}); + +export const DevicePairingCodeSchema = z.string().min(16).max(256); +export const DeviceNameSchema = z.string().trim().min(1).max(80); + +export const RedeemDevicePairingCodeRequestSchema = z.object({ + code: DevicePairingCodeSchema, + deviceName: DeviceNameSchema.optional(), +}); + +export const RedeemDevicePairingCodeResponseSchema = z.object({ + user: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }), +}); + +export const AppLinkBlockedReasonSchema = z.enum(["insecure-origin", "loopback"]); +export const DevicePairingLinkWarningSchema = z.enum(["plain-http", "loopback", "headers-unreadable"]); + +export const CreateDevicePairingLinksRequestSchema = z.object({ + name: z.string().trim().min(1).max(100).optional(), +}); + +export const DevicePairingLinksResponseSchema = z.object({ + appLink: z.string().nullable(), + appLinkBlockedReason: AppLinkBlockedReasonSchema.nullable(), + browserLink: z.string(), + expiresAt: z.string(), + headerNames: z.array(z.string()), + warnings: z.array(DevicePairingLinkWarningSchema), +}); + +export const PairedDeviceSchema = z.object({ + id: z.string(), + deviceName: z.string().nullable(), + userAgent: z.string().nullable(), + ipAddress: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + isCurrent: z.boolean(), +}); + +export const PairedDevicesResponseSchema = z.object({ + devices: z.array(PairedDeviceSchema), +}); + +export type RemoteAccessHeader = z.infer; +export type RemoteAccessHeaderDraftValue = z.infer; +export type RemoteAccessHeaderList = z.infer; +export type RemoteAccessHeaderSummary = z.infer; +export type RemoteAccessHeadersResponse = z.infer; +export type ReplaceRemoteAccessHeadersRequest = z.infer; +export type DevicePairingCode = z.infer; +export type DeviceName = z.infer; +export type RedeemDevicePairingCodeRequest = z.infer; +export type RedeemDevicePairingCodeResponse = z.infer; +export type AppLinkBlockedReason = z.infer; +export type DevicePairingLinkWarning = z.infer; +export type CreateDevicePairingLinksRequest = z.infer; +export type DevicePairingLinksResponse = z.infer; +export type PairedDevice = z.infer; +export type PairedDevicesResponse = z.infer; +export type DevicePairingErrorCode = + (typeof DEVICE_PAIRING_ERROR_CODES)[keyof typeof DEVICE_PAIRING_ERROR_CODES]; + +/** Builds the browser pairing link whose code stays in the URL fragment. */ +export function buildManagerBrowserPairingLink(origin: string, code: string): string { + return `${trimUrlTrailingSlashes(origin)}${DEVICE_PAIRING_PATH}#c=${encodeURIComponent(code)}`; +} + +/** Reads a pairing code from a URL fragment, or null when the fragment holds no valid code. */ +export function readPairingCodeFromHash(hash: string): string | null { + const code = new URLSearchParams(hash.replace(/^#/, "")).get("c"); + const parsed = DevicePairingCodeSchema.safeParse(code); + return parsed.success ? parsed.data : null; +} + +/** Parses a browser pairing link into its origin and code, or null when the text is not one. */ +export function parseManagerBrowserPairingLink(text: string): { origin: string; code: string } | null { + let url: URL; + try { + url = new URL(text.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + if (url.pathname !== DEVICE_PAIRING_PATH) return null; + const code = readPairingCodeFromHash(url.hash); + return code === null ? null : { origin: url.origin, code }; +} diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index 29ae7ad7d..b295a360b 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -25,3 +25,4 @@ export * from './preview' export * from './change-walkthroughs' export * from './opencode-servers' export * from './secrets' +export * from './device-pairing' diff --git a/shared/src/schemas/opencode-servers.ts b/shared/src/schemas/opencode-servers.ts index 5072e0dd7..1d2033609 100644 --- a/shared/src/schemas/opencode-servers.ts +++ b/shared/src/schemas/opencode-servers.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + DevicePairingCodeSchema, + RemoteAccessHeaderListSchema, + formatRemoteAccessHeaderParam, +} from "./device-pairing"; export const LOCAL_OPENCODE_SERVER_ID = "local"; export const OPENCODE_SERVER_USERNAME = "opencode"; @@ -77,6 +82,14 @@ export const OpenCodeServerLinkSchema = z.object({ pairing: OpenCodeServerPairingSchema, }); +export const ManagerAppLinkSchema = z.object({ + kind: z.literal("manager"), + name: z.string().trim().min(1).max(100), + baseUrl: HttpUrlSchema, + code: DevicePairingCodeSchema, + headers: RemoteAccessHeaderListSchema, +}); + export type OpenCodeServer = z.infer; export type OpenCodeServerPairing = z.infer; export type CreateOpenCodeServerRequest = z.input; @@ -85,33 +98,42 @@ export type TestOpenCodeServerRequest = z.input; export type OpenCodeServerStatus = z.infer; export type OpenCodeServerLink = z.infer; +export type ManagerAppLink = z.infer; /** Builds the `ocm://link` URL encoded into a pairing QR code. */ export function buildOpenCodeServerLink(link: OpenCodeServerLink): string { - const params = new URLSearchParams({ - v: OPENCODE_SERVER_LINK_VERSION, - kind: link.kind, - name: link.name, - url: link.baseUrl, - pair: link.pairing.pairUrl, - code: link.pairing.code, - }); - return `${OPENCODE_SERVER_LINK_PROTOCOL}//link?${params.toString()}`; + const params = buildLinkParams(link); + params.set("pair", link.pairing.pairUrl); + params.set("code", link.pairing.code); + return buildLinkUrl(params); } /** Parses an `ocm://link` URL from a QR code or pasted text; returns null when it is not a valid link. */ export function parseOpenCodeServerLink(text: string): OpenCodeServerLink | null { - const url = parseOpenCodeServerLinkUrl(text); - if (!url) return null; - if (url.searchParams.get("v") !== OPENCODE_SERVER_LINK_VERSION) return null; - - const parsed = OpenCodeServerLinkSchema.safeParse({ - kind: url.searchParams.get("kind"), - name: url.searchParams.get("name"), - baseUrl: url.searchParams.get("url"), + return readLink(OpenCodeServerLinkSchema, text, (url) => ({ pairing: { pairUrl: url.searchParams.get("pair"), code: url.searchParams.get("code") }, + })); +} + +/** Builds the `ocm://link` URL for a manager app pairing QR code, including remote-access headers. */ +export function buildManagerAppLink(link: ManagerAppLink): string { + const params = buildLinkParams(link); + params.set("code", link.code); + for (const header of link.headers) params.append("h", formatRemoteAccessHeaderParam(header)); + return buildLinkUrl(params); +} + +/** Parses an `ocm://link` manager app link; returns null when it is not a valid manager link. */ +export function parseManagerAppLink(text: string): ManagerAppLink | null { + return readLink(ManagerAppLinkSchema, text, (url) => { + const headers: Array<{ name: string; value: string }> = []; + for (const raw of url.searchParams.getAll("h")) { + const separator = raw.indexOf(":"); + if (separator < 0) return null; + headers.push({ name: raw.slice(0, separator), value: raw.slice(separator + 1) }); + } + return { headers }; }); - return parsed.success ? parsed.data : null; } /** Reads the `v` parameter of an `ocm://link` URL, or null when the text is not an `ocm://link` URL. */ @@ -130,6 +152,53 @@ function parseOpenCodeServerLinkUrl(text: string): URL | null { return url; } +type LinkQueryFields = { + kind: string | null; + name: string | null; + baseUrl: string | null; + code: string | null; +}; + +function buildLinkParams(link: { kind: string; name: string; baseUrl: string }): URLSearchParams { + return new URLSearchParams({ + v: OPENCODE_SERVER_LINK_VERSION, + kind: link.kind, + name: link.name, + url: link.baseUrl, + }); +} + +function buildLinkUrl(params: URLSearchParams): string { + return `${OPENCODE_SERVER_LINK_PROTOCOL}//link?${params.toString()}`; +} + +function readLinkQueryFields(text: string): { url: URL; fields: LinkQueryFields } | null { + const url = parseOpenCodeServerLinkUrl(text); + if (!url || url.searchParams.get("v") !== OPENCODE_SERVER_LINK_VERSION) return null; + return { + url, + fields: { + kind: url.searchParams.get("kind"), + name: url.searchParams.get("name"), + baseUrl: url.searchParams.get("url"), + code: url.searchParams.get("code"), + }, + }; +} + +function readLink( + schema: z.ZodType, + text: string, + readDetails: (url: URL) => Record | null, +): T | null { + const link = readLinkQueryFields(text); + if (!link) return null; + const details = readDetails(link.url); + if (!details) return null; + const parsed = schema.safeParse({ ...link.fields, ...details }); + return parsed.success ? parsed.data : null; +} + const REMOTE_DIRECTORY_KEY_PATTERN = /^(srv_[A-Za-z0-9_-]+)\|(.+)$/s; /** Builds the event-stream subscription key for a directory; local directories stay unchanged. */ diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index e1ea34960..f135b3768 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -205,6 +205,12 @@ export const DEFAULT_SESSION_DEFAULTS = { goalMaxContinuations: 20, } satisfies SessionDefaults; +export const DevicePairingPreferencesSchema = z.object({ + allowPrivateNetworkHttp: z.boolean().optional(), +}); + +export type DevicePairingPreferences = z.infer; + export const UserPreferencesSchema = z.object({ theme: z.enum(["dark", "light", "system"]), colorTheme: ColorThemeIdSchema.optional(), @@ -236,6 +242,7 @@ export const UserPreferencesSchema = z.object({ serverEnvVars: z.array(ServerEnvVarSchema).optional(), sandbox: SandboxPreferencesSchema.optional(), sessionDefaults: SessionDefaultsSchema.optional(), + devicePairing: DevicePairingPreferencesSchema.optional(), disabledDefaultServerEnvVars: z.array(z.string()).optional(), }); diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index f52fff3c9..4871d5da8 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './semver' export * from './terminal-title' export * from './unified-diff' export * from './url' +export * from './user-agent' diff --git a/shared/src/utils/url.ts b/shared/src/utils/url.ts index 0c798b43c..e250dfc59 100644 --- a/shared/src/utils/url.ts +++ b/shared/src/utils/url.ts @@ -9,6 +9,50 @@ export function haveSameUrlOrigin(first: string, second: string): boolean { return origin !== null && origin === readUrlOrigin(second) } +/** Reports whether a hostname refers to the local machine. */ +export function isLoopbackHost(hostname: string): boolean { + const host = normalizeHostname(hostname) + if (host === 'localhost' || host === '::1') return true + const octets = parseIpv4Octets(host) + return octets !== null && octets[0] === 127 +} + +/** Reports whether a hostname is an RFC 1918, link-local, or CGNAT address. */ +export function isPrivateNetworkHost(hostname: string): boolean { + const host = normalizeHostname(hostname) + const octets = parseIpv4Octets(host) + if (octets !== null) { + const [first, second] = octets + return ( + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) || + (first === 100 && second >= 64 && second <= 127) + ) + } + if (!host.includes(':')) return false + const firstHextet = Number.parseInt(host.split(':')[0] ?? '', 16) + return firstHextet >= 0xfc00 && firstHextet <= 0xfdff +} + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/^\[/, '').replace(/\]$/, '') +} + +function parseIpv4Octets(host: string): [number, number, number, number] | null { + const parts = host.split('.') + if (parts.length !== 4) return null + const octets: [number, number, number, number] = [0, 0, 0, 0] + for (const [index, part] of parts.entries()) { + if (!/^\d{1,3}$/.test(part)) return null + const value = Number(part) + if (value > 255) return null + octets[index] = value + } + return octets +} + function readUrlOrigin(value: string): string | null { try { return new URL(value).origin diff --git a/shared/src/utils/user-agent.ts b/shared/src/utils/user-agent.ts new file mode 100644 index 000000000..ea294377d --- /dev/null +++ b/shared/src/utils/user-agent.ts @@ -0,0 +1,32 @@ +const UNKNOWN_DEVICE = 'Unknown device' +const RAW_USER_AGENT_MAX_LENGTH = 80 + +/** Describes a user agent as "browser on platform", falling back to the raw string. */ +export function describeUserAgent(userAgent: string | null | undefined): string { + const value = userAgent?.trim() + if (!value) return UNKNOWN_DEVICE + + const platform = detectPlatform(value) + const browser = detectBrowser(value) + if (browser && platform) return `${browser} on ${platform}` + return browser ?? platform ?? value.slice(0, RAW_USER_AGENT_MAX_LENGTH) +} + +function detectPlatform(userAgent: string): string | null { + if (userAgent.includes('iPhone')) return 'iPhone' + if (userAgent.includes('iPad')) return 'iPad' + if (userAgent.includes('Android')) return 'Android' + if (userAgent.includes('Macintosh')) return 'macOS' + if (userAgent.includes('Windows')) return 'Windows' + if (userAgent.includes('Linux')) return 'Linux' + return null +} + +function detectBrowser(userAgent: string): string | null { + if (userAgent.includes('Edg/')) return 'Edge' + if (userAgent.includes('OPR/')) return 'Opera' + if (userAgent.includes('Firefox/') || userAgent.includes('FxiOS/')) return 'Firefox' + if (userAgent.includes('CriOS/') || userAgent.includes('Chrome/')) return 'Chrome' + if (userAgent.includes('Safari/') && userAgent.includes('Version/')) return 'Safari' + return null +} From 106e8f51fc0cf220398cd728a9a3d7ddf08dec41 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:00:40 -0400 Subject: [PATCH 2/6] fix(frontend): show a message when the QR renderer fails to load A rejected dynamic import left the pairing dialog blank forever. The renderer now loads through async/await with a catch and renders an error instead of nothing when the chunk cannot be fetched. --- .../settings/PairingQrCode.test.tsx | 22 ++++++++++++++++++- .../src/components/settings/PairingQrCode.tsx | 21 +++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/settings/PairingQrCode.test.tsx b/frontend/src/components/settings/PairingQrCode.test.tsx index d8839e976..0eca25430 100644 --- a/frontend/src/components/settings/PairingQrCode.test.tsx +++ b/frontend/src/components/settings/PairingQrCode.test.tsx @@ -1,8 +1,28 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { render, screen } from '@testing-library/react' import { PairingQrCode } from './PairingQrCode' describe('PairingQrCode', () => { + afterEach(() => { + vi.doUnmock('uqr') + vi.resetModules() + }) + + it('reports a renderer that fails to load', async () => { + vi.resetModules() + vi.doMock('uqr', () => { + throw new Error('chunk failed') + }) + const { PairingQrCode: FreshPairingQrCode } = await import('./PairingQrCode') + + render() + + expect( + await screen.findByText('The QR code could not be loaded. Reload the page and try again.'), + ).toBeInTheDocument() + expect(screen.queryByAltText('Pairing QR code')).not.toBeInTheDocument() + }) + it('renders the QR code once uqr has loaded', async () => { render() diff --git a/frontend/src/components/settings/PairingQrCode.tsx b/frontend/src/components/settings/PairingQrCode.tsx index f4cf6f23e..d7ce3b7cd 100644 --- a/frontend/src/components/settings/PairingQrCode.tsx +++ b/frontend/src/components/settings/PairingQrCode.tsx @@ -11,12 +11,19 @@ interface PairingQrCodeProps { export function PairingQrCode({ value, label, blurred = false }: PairingQrCodeProps) { const [renderSvg, setRenderSvg] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) useEffect(() => { let active = true - void import('uqr').then((module) => { - if (active) setRenderSvg(() => module.renderSVG) - }) + const loadRenderer = async () => { + try { + const module = await import('uqr') + if (active) setRenderSvg(() => module.renderSVG) + } catch { + if (active) setLoadFailed(true) + } + } + void loadRenderer() return () => { active = false } @@ -31,6 +38,14 @@ export function PairingQrCode({ value, label, blurred = false }: PairingQrCodePr } }, [renderSvg, value]) + if (loadFailed) { + return ( +

+ The QR code could not be loaded. Reload the page and try again. +

+ ) + } + if (!renderSvg) return null if (!source) { From b9d8d1812446ea83e3cb3670d9f87503a91092be Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:29:51 -0400 Subject: [PATCH 3/6] fix(device-pairing): refuse to mint pairing codes from an address that cannot pair A loopback origin in production or plain HTTP on a public address still minted a code and showed a browser QR no other device could use. - one availability rule decides untrusted, loopback and public plain-HTTP origins; POST /api/device-pairing/links checks it before minting - GET /api/device-pairing/availability reports the rule for the page origin without minting - Connect a device disables "Show QR code" and explains the fix when the address cannot pair - private-network plain HTTP keeps the browser QR; the app QR still needs the toggle --- backend/src/routes/device-pairing.ts | 50 +++-- backend/src/services/device-pairing-links.ts | 86 +++++++-- backend/test/routes/device-pairing.test.ts | 172 ++++++++++++++++-- .../services/device-pairing-links.test.ts | 113 ++++++++++++ .../shared/device-pairing-schemas.test.ts | 36 ++++ frontend/src/api/devicePairing.ts | 11 ++ .../settings/ConnectDeviceDialog.test.tsx | 13 +- .../settings/ConnectDeviceDialog.tsx | 9 +- .../settings/ConnectDeviceSettings.test.tsx | 51 +++++- .../settings/ConnectDeviceSettings.tsx | 22 ++- frontend/src/hooks/useDevicePairing.ts | 11 ++ shared/src/schemas/device-pairing.ts | 15 +- 12 files changed, 527 insertions(+), 62 deletions(-) create mode 100644 backend/test/services/device-pairing-links.test.ts diff --git a/backend/src/routes/device-pairing.ts b/backend/src/routes/device-pairing.ts index e31284925..b4dcea0ff 100644 --- a/backend/src/routes/device-pairing.ts +++ b/backend/src/routes/device-pairing.ts @@ -1,18 +1,26 @@ import { Hono } from 'hono' -import { ENV, getTrustedOrigins, isTrustedOrigin } from '@opencode-manager/shared/config/env' +import type { MiddlewareHandler } from 'hono' +import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env' import { CreateDevicePairingLinksRequestSchema } from '@opencode-manager/shared/schemas' -import { buildDevicePairingLinks, resolveAppLinkPolicy } from '../services/device-pairing-links' +import { + buildDevicePairingLinks, + normalizeDevicePairingOrigin, + resolveAppLinkPolicy, + resolveDevicePairingAvailability, +} from '../services/device-pairing-links' import type { RemoteAccessHeadersService } from '../services/remote-access-headers' import { logger } from '../utils/logger' import { parseJsonBody } from '../utils/route-helpers' -const MISSING_ORIGIN_MESSAGE = - 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.' const LINKS_FAILED_MESSAGE = 'Failed to create device pairing links' +const MISSING_ORIGIN_QUERY_MESSAGE = + 'Add an origin query parameter, for example ?origin=https://manager.example.com.' +const INVALID_ORIGIN_QUERY_MESSAGE = + 'The origin query parameter must be an absolute http or https URL.' -function untrustedOriginMessage(origin: string | undefined): string { - if (!origin) return MISSING_ORIGIN_MESSAGE - return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.` +const noStore: MiddlewareHandler = async (c, next) => { + await next() + c.header('Cache-Control', 'no-store') } interface DevicePairingRouteDeps { @@ -29,9 +37,22 @@ export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) { const resolveTrustedOrigins = deps.resolveTrustedOrigins ?? getTrustedOrigins const isDevelopment = deps.isDevelopment ?? (() => ENV.SERVER.NODE_ENV === 'development') - app.use('/links', async (c, next) => { - await next() - c.header('Cache-Control', 'no-store') + app.use('/links', noStore) + app.use('/availability', noStore) + + app.get('/availability', (c) => { + const requestedOrigin = c.req.query('origin') + if (!requestedOrigin) return c.json({ error: MISSING_ORIGIN_QUERY_MESSAGE }, 400) + + const origin = normalizeDevicePairingOrigin(requestedOrigin) + if (!origin) return c.json({ error: INVALID_ORIGIN_QUERY_MESSAGE }, 400) + + return c.json( + resolveDevicePairingAvailability(origin, { + trustedOrigins: resolveTrustedOrigins(), + isDevelopment: isDevelopment(), + }), + ) }) app.post('/links', async (c) => { @@ -39,15 +60,18 @@ export function createDevicePairingRoutes(deps: DevicePairingRouteDeps) { if (!body.ok) return body.response const origin = c.req.header('origin') - if (!origin || !isTrustedOrigin(origin, { requireOrigin: true }, resolveTrustedOrigins())) { - return c.json({ error: untrustedOriginMessage(origin) }, 400) + const availability = resolveDevicePairingAvailability(origin, { + trustedOrigins: resolveTrustedOrigins(), + isDevelopment: isDevelopment(), + }) + if (!origin || !availability.available) { + return c.json({ error: availability.message, code: availability.reason }, 400) } try { const originUrl = new URL(origin) const policy = resolveAppLinkPolicy(originUrl, { allowPrivateNetworkHttp: deps.readAllowPrivateNetworkHttp(), - isDevelopment: isDevelopment(), }) const stored = deps.remoteAccessHeaders.read() const minted = await deps.mintPairingCode(c.req.raw.headers) diff --git a/backend/src/services/device-pairing-links.ts b/backend/src/services/device-pairing-links.ts index 893aa2ff8..712fa9f46 100644 --- a/backend/src/services/device-pairing-links.ts +++ b/backend/src/services/device-pairing-links.ts @@ -1,39 +1,101 @@ +import { isTrustedOrigin } from '@opencode-manager/shared/config/env' import { buildManagerAppLink, buildManagerBrowserPairingLink, type AppLinkBlockedReason, + type DevicePairingAvailability, type DevicePairingLinkWarning, type DevicePairingLinksResponse, + type DevicePairingUnavailableReason, type RemoteAccessHeader, } from '@opencode-manager/shared/schemas' import { isLoopbackHost, isPrivateNetworkHost } from '@opencode-manager/shared/utils' +export const MISSING_ORIGIN_MESSAGE = + 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.' +export const LOOPBACK_ORIGIN_MESSAGE = + 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.' +export const INSECURE_ORIGIN_MESSAGE = + 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.' + type AppLinkPolicy = { blockedReason: AppLinkBlockedReason | null warnings: DevicePairingLinkWarning[] } +type DevicePairingAvailabilityOptions = { + trustedOrigins: string[] + isDevelopment: boolean +} + +function untrustedOriginMessage(origin: string): string { + return `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.` +} + +function unavailable( + reason: DevicePairingUnavailableReason, + message: string, +): DevicePairingAvailability { + return { available: false, reason, message } +} + +function parseHttpOrigin(value: string): URL | null { + let url: URL + try { + url = new URL(value) + } catch { + return null + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url +} + +/** Normalises an address to its origin, or null when it is not an absolute http(s) URL. */ +export function normalizeDevicePairingOrigin(value: string): string | null { + return parseHttpOrigin(value)?.origin ?? null +} + +/** Reports whether an origin can mint a pairing code, and why it cannot when blocked. */ +export function resolveDevicePairingAvailability( + origin: string | undefined, + options: DevicePairingAvailabilityOptions, +): DevicePairingAvailability { + if (!origin) return unavailable('untrusted-origin', MISSING_ORIGIN_MESSAGE) + if (!isTrustedOrigin(origin, { requireOrigin: true }, options.trustedOrigins)) { + return unavailable('untrusted-origin', untrustedOriginMessage(origin)) + } + + const originUrl = parseHttpOrigin(origin) + if (!originUrl) return unavailable('untrusted-origin', untrustedOriginMessage(origin)) + + const isLoopback = isLoopbackHost(originUrl.hostname) + if (isLoopback && !options.isDevelopment) { + return unavailable('loopback', LOOPBACK_ORIGIN_MESSAGE) + } + + if (originUrl.protocol === 'http:' && !isLoopback && !isPrivateNetworkHost(originUrl.hostname)) { + return unavailable('insecure-origin', INSECURE_ORIGIN_MESSAGE) + } + + return { available: true, reason: null, message: null } +} + /** Decides whether an origin can carry an app link and which warnings it raises. */ export function resolveAppLinkPolicy( origin: URL, - options: { allowPrivateNetworkHttp: boolean; isDevelopment: boolean }, + options: { allowPrivateNetworkHttp: boolean }, ): AppLinkPolicy { const warnings: DevicePairingLinkWarning[] = [] const isPlainHttp = origin.protocol === 'http:' + const isLoopback = isLoopbackHost(origin.hostname) if (isPlainHttp) warnings.push('plain-http') + if (isLoopback) warnings.push('loopback') + if (!isPlainHttp) return { blockedReason: null, warnings } - if (isLoopbackHost(origin.hostname)) { - warnings.push('loopback') - return { blockedReason: options.isDevelopment ? null : 'loopback', warnings } - } - - if (isPlainHttp) { - const isAllowed = options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname) - return { blockedReason: isAllowed ? null : 'insecure-origin', warnings } - } - - return { blockedReason: null, warnings } + const isAllowed = + isLoopback || (options.allowPrivateNetworkHttp && isPrivateNetworkHost(origin.hostname)) + return { blockedReason: isAllowed ? null : 'insecure-origin', warnings } } /** Builds the pairing link response, dropping the app link when its origin is blocked. */ diff --git a/backend/test/routes/device-pairing.test.ts b/backend/test/routes/device-pairing.test.ts index dc40b9f01..a583b6415 100644 --- a/backend/test/routes/device-pairing.test.ts +++ b/backend/test/routes/device-pairing.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { + DevicePairingAvailabilitySchema, DevicePairingLinksResponseSchema, buildManagerBrowserPairingLink, parseManagerAppLink, + type DevicePairingAvailability, type DevicePairingLinksResponse, } from '@opencode-manager/shared/schemas' @@ -41,6 +43,10 @@ const MISSING_ORIGIN_ERROR = 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.' const untrustedOriginError = (origin: string) => `This address (${origin}) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.` +const LOOPBACK_ORIGIN_ERROR = + 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.' +const INSECURE_ORIGIN_ERROR = + 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.' const LINKS_FAILED_ERROR = 'Failed to create device pairing links' const SENSITIVE_VALUES = [CODE, ...CLOUDFLARE_SECRET_VALUES] @@ -57,6 +63,10 @@ async function readLinksBody(response: Response): Promise { + return DevicePairingAvailabilitySchema.parse(await response.json()) +} + describe('device pairing link routes', () => { let db: Database let remoteAccessHeaders: RemoteAccessHeadersService @@ -103,12 +113,24 @@ describe('device pairing link routes', () => { }) } + const getAvailability = (origin?: string) => { + const path = + origin === undefined + ? '/device-pairing/availability' + : `/device-pairing/availability?origin=${encodeURIComponent(origin)}` + + return app.request(path, { headers: { cookie: COOKIE_HEADER } }) + } + it('rejects a request without an origin', async () => { const res = await postLinks() expect(res.status).toBe(400) expect(res.headers.get('Cache-Control')).toBe('no-store') - await expect(res.json()).resolves.toEqual({ error: MISSING_ORIGIN_ERROR }) + await expect(res.json()).resolves.toEqual({ + error: MISSING_ORIGIN_ERROR, + code: 'untrusted-origin', + }) expect(mintPairingCode).not.toHaveBeenCalled() }) @@ -117,9 +139,10 @@ describe('device pairing link routes', () => { expect(res.status).toBe(400) expect(res.headers.get('Cache-Control')).toBe('no-store') - const body = await res.json() as { error: string } + const body = await res.json() as { error: string; code: string } expect(body.error).toBe(untrustedOriginError('https://evil.example')) expect(body.error).toContain('AUTH_TRUSTED_ORIGINS') + expect(body.code).toBe('untrusted-origin') expect(mintPairingCode).not.toHaveBeenCalled() }) @@ -127,7 +150,10 @@ describe('device pairing link routes', () => { const res = await postLinks('null') expect(res.status).toBe(400) - await expect(res.json()).resolves.toEqual({ error: untrustedOriginError('null') }) + await expect(res.json()).resolves.toEqual({ + error: untrustedOriginError('null'), + code: 'untrusted-origin', + }) expect(mintPairingCode).not.toHaveBeenCalled() }) @@ -195,11 +221,13 @@ describe('device pairing link routes', () => { const res = await postLinks('http://203.0.113.5:5003') - expect(res.status).toBe(200) - const body = await readLinksBody(res) - - expect(body.appLink).toBeNull() - expect(body.appLinkBlockedReason).toBe('insecure-origin') + expect(res.status).toBe(400) + expect(res.headers.get('Cache-Control')).toBe('no-store') + await expect(res.json()).resolves.toEqual({ + error: INSECURE_ORIGIN_ERROR, + code: 'insecure-origin', + }) + expect(mintPairingCode).not.toHaveBeenCalled() }) it('blocks a loopback origin outside development', async () => { @@ -208,12 +236,12 @@ describe('device pairing link routes', () => { const res = await postLinks('http://localhost:5173') - expect(res.status).toBe(200) - const body = await readLinksBody(res) - - expect(body.appLink).toBeNull() - expect(body.appLinkBlockedReason).toBe('loopback') - expect(body.warnings).toEqual(['plain-http', 'loopback']) + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ + error: LOOPBACK_ORIGIN_ERROR, + code: 'loopback', + }) + expect(mintPairingCode).not.toHaveBeenCalled() }) it('allows a loopback origin in development', async () => { @@ -226,6 +254,7 @@ describe('device pairing link routes', () => { const body = await readLinksBody(res) expect(parseManagerAppLink(body.appLink ?? '')?.baseUrl).toBe('http://localhost:5173') + expect(body.warnings).toEqual(['plain-http', 'loopback']) }) it('reports unreadable stored headers', async () => { @@ -257,4 +286,119 @@ describe('device pairing link routes', () => { expectNoSecretsLogged(loggerMocks, SENSITIVE_VALUES) }) + + describe('GET availability', () => { + it('reports a trusted https origin as available without minting', async () => { + const res = await getAvailability('https://m.example.com') + + expect(res.status).toBe(200) + expect(res.headers.get('Cache-Control')).toBe('no-store') + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: true, + reason: null, + message: null, + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('reports an untrusted origin', async () => { + const res = await getAvailability('https://evil.example') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: false, + reason: 'untrusted-origin', + message: untrustedOriginError('https://evil.example'), + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('reports a loopback origin outside development', async () => { + development = false + + const res = await getAvailability('http://localhost:5173') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: false, + reason: 'loopback', + message: LOOPBACK_ORIGIN_ERROR, + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('reports a loopback origin in development as available', async () => { + development = true + + const res = await getAvailability('http://localhost:5173') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: true, + reason: null, + message: null, + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('reports a public http origin as insecure', async () => { + allowPrivateNetwork = true + + const res = await getAvailability('http://203.0.113.5:5003') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: false, + reason: 'insecure-origin', + message: INSECURE_ORIGIN_ERROR, + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('keeps a private network http origin available so the browser link can be shown', async () => { + const res = await getAvailability('http://192.168.1.20:5003') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: true, + reason: null, + message: null, + }) + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it('normalises the supplied address to its origin', async () => { + const res = await getAvailability('https://m.example.com/settings?tab=pairing') + + expect(res.status).toBe(200) + await expect(readAvailabilityBody(res)).resolves.toEqual({ + available: true, + reason: null, + message: null, + }) + }) + + it('rejects a missing origin query parameter', async () => { + const res = await getAvailability() + + expect(res.status).toBe(400) + expect(res.headers.get('Cache-Control')).toBe('no-store') + const body = await res.json() as { error: string } + expect(body.error).toContain('origin query parameter') + expect(mintPairingCode).not.toHaveBeenCalled() + }) + + it.each([ + ['garbage text', 'not a url'], + ['a non-http scheme', 'ocm://link?v=2&kind=manager'], + ['a scheme-relative address', '//m.example.com'], + ])('rejects %s in the origin query parameter', async (_label, origin) => { + const res = await getAvailability(origin) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toContain('absolute http or https URL') + expect(mintPairingCode).not.toHaveBeenCalled() + }) + }) }) diff --git a/backend/test/services/device-pairing-links.test.ts b/backend/test/services/device-pairing-links.test.ts new file mode 100644 index 000000000..1e05160e2 --- /dev/null +++ b/backend/test/services/device-pairing-links.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { resolveAppLinkPolicy, resolveDevicePairingAvailability } from '../../src/services/device-pairing-links' + +const TRUSTED_ORIGINS = [ + 'https://m.example.com', + 'http://192.168.1.20:5003', + 'http://203.0.113.5:5003', + 'http://localhost:5173', +] + +const production = { trustedOrigins: TRUSTED_ORIGINS, isDevelopment: false } +const development = { trustedOrigins: TRUSTED_ORIGINS, isDevelopment: true } + +describe('resolveDevicePairingAvailability', () => { + it('allows a trusted https origin', () => { + expect(resolveDevicePairingAvailability('https://m.example.com', production)).toEqual({ + available: true, + reason: null, + message: null, + }) + }) + + it('rejects a missing origin', () => { + expect(resolveDevicePairingAvailability(undefined, production)).toEqual({ + available: false, + reason: 'untrusted-origin', + message: + 'The request had no Origin header. Open the Manager at an address listed in AUTH_TRUSTED_ORIGINS to pair a device.', + }) + }) + + it('rejects the null origin', () => { + expect(resolveDevicePairingAvailability('null', production)).toEqual({ + available: false, + reason: 'untrusted-origin', + message: + 'This address (null) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.', + }) + }) + + it('rejects a loopback origin outside development', () => { + expect(resolveDevicePairingAvailability('http://localhost:5173', production)).toEqual({ + available: false, + reason: 'loopback', + message: + 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.', + }) + }) + + it('allows a loopback origin in development', () => { + expect(resolveDevicePairingAvailability('http://localhost:5173', development).available).toBe(true) + }) + + it('rejects public plain http', () => { + expect(resolveDevicePairingAvailability('http://203.0.113.5:5003', production)).toEqual({ + available: false, + reason: 'insecure-origin', + message: + 'Pairing needs HTTPS on a public address. Serve the Manager over HTTPS, or open it at a private network address.', + }) + }) + + it('allows private network plain http', () => { + expect(resolveDevicePairingAvailability('http://192.168.1.20:5003', production).available).toBe(true) + }) + + it('rejects a trusted origin that is not an http url', () => { + const options = { trustedOrigins: ['ocm://link'], isDevelopment: false } + + expect(resolveDevicePairingAvailability('ocm://link', options)).toEqual({ + available: false, + reason: 'untrusted-origin', + message: + 'This address (ocm://link) is not in AUTH_TRUSTED_ORIGINS. Add it and restart the Manager to pair a device.', + }) + }) +}) + +describe('resolveAppLinkPolicy', () => { + it('allows an https origin without warnings', () => { + expect(resolveAppLinkPolicy(new URL('https://m.example.com'), { allowPrivateNetworkHttp: false })).toEqual({ + blockedReason: null, + warnings: [], + }) + }) + + it('blocks a private network http app link without the toggle', () => { + expect( + resolveAppLinkPolicy(new URL('http://192.168.1.20:5003'), { allowPrivateNetworkHttp: false }), + ).toEqual({ + blockedReason: 'insecure-origin', + warnings: ['plain-http'], + }) + }) + + it('allows a private network http app link with the toggle', () => { + expect( + resolveAppLinkPolicy(new URL('http://192.168.1.20:5003'), { allowPrivateNetworkHttp: true }), + ).toEqual({ + blockedReason: null, + warnings: ['plain-http'], + }) + }) + + it('warns about loopback without blocking the app link itself', () => { + expect( + resolveAppLinkPolicy(new URL('http://localhost:5173'), { allowPrivateNetworkHttp: true }), + ).toEqual({ + blockedReason: null, + warnings: ['plain-http', 'loopback'], + }) + }) +}) diff --git a/backend/test/shared/device-pairing-schemas.test.ts b/backend/test/shared/device-pairing-schemas.test.ts index 5cade570c..686ec6e3e 100644 --- a/backend/test/shared/device-pairing-schemas.test.ts +++ b/backend/test/shared/device-pairing-schemas.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + AppLinkBlockedReasonSchema, + DevicePairingAvailabilitySchema, REMOTE_ACCESS_HEADERS_MAX_ENCODED_LENGTH, REMOTE_ACCESS_HEADER_VALUE_MAX_LENGTH, RedeemDevicePairingCodeResponseSchema, @@ -171,6 +173,40 @@ describe('RemoteAccessHeaderDraftValueSchema', () => { }) }) +describe('DevicePairingAvailabilitySchema', () => { + it('accepts an available origin', () => { + expect( + DevicePairingAvailabilitySchema.parse({ available: true, reason: null, message: null }), + ).toEqual({ available: true, reason: null, message: null }) + }) + + it.each(['untrusted-origin', 'loopback', 'insecure-origin'])( + 'accepts the unavailable reason %s', + (reason) => { + expect( + DevicePairingAvailabilitySchema.parse({ available: false, reason, message: 'blocked' }), + ).toEqual({ available: false, reason, message: 'blocked' }) + }, + ) + + it('rejects an unknown reason', () => { + expect( + DevicePairingAvailabilitySchema.safeParse({ available: false, reason: 'no-origin', message: 'x' }) + .success, + ).toBe(false) + }) +}) + +describe('AppLinkBlockedReasonSchema', () => { + it('accepts an insecure origin', () => { + expect(AppLinkBlockedReasonSchema.parse('insecure-origin')).toBe('insecure-origin') + }) + + it('no longer accepts loopback, which now blocks pairing entirely', () => { + expect(AppLinkBlockedReasonSchema.safeParse('loopback').success).toBe(false) + }) +}) + describe('RedeemDevicePairingCodeResponseSchema', () => { it('parses the user returned by the redeem endpoint', () => { const response = { user: { id: 'user_1', name: 'Chris', email: 'chris@example.com' } } diff --git a/frontend/src/api/devicePairing.ts b/frontend/src/api/devicePairing.ts index 77543f270..c6405bbe2 100644 --- a/frontend/src/api/devicePairing.ts +++ b/frontend/src/api/devicePairing.ts @@ -1,5 +1,6 @@ import type { CreateDevicePairingLinksRequest, + DevicePairingAvailability, DevicePairingLinksResponse, PairedDevice, PairedDevicesResponse, @@ -62,6 +63,16 @@ export async function createDevicePairingLinks( }) } +/** Reads whether the Manager can mint pairing codes for an address without minting one. */ +export async function getDevicePairingAvailability( + origin: string, +): Promise { + return fetchWrapper( + `${API_BASE_URL}/api/device-pairing/availability`, + { params: { origin } }, + ) +} + /** Lists the devices signed in to this account. */ export async function listPairedDevices(): Promise { const response = await fetchWrapper( diff --git a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx index a25745610..a0944810a 100644 --- a/frontend/src/components/settings/ConnectDeviceDialog.test.tsx +++ b/frontend/src/components/settings/ConnectDeviceDialog.test.tsx @@ -90,27 +90,20 @@ describe('ConnectDeviceDialog', () => { expect(await screen.findByAltText('Browser sign-in QR code')).toBeInTheDocument() }) - it('explains a loopback block without showing the mobile app QR code', async () => { + it('warns about a localhost address while still showing the mobile app QR code', async () => { mocks.createDevicePairingLinks.mockResolvedValue({ ...BASE_LINKS, - appLink: null, - appLinkBlockedReason: 'loopback', warnings: ['plain-http', 'loopback'], }) renderDialog() + expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument() expect( - await screen.findByText( - 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.', - ), + screen.getByText('This address is localhost; other devices cannot reach it.'), ).toBeInTheDocument() - expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument() expect( screen.getByText('This address uses plain HTTP, so the sign-in code travels unencrypted.'), ).toBeInTheDocument() - expect( - screen.getByText('This address is localhost; other devices cannot reach it.'), - ).toBeInTheDocument() }) it('explains how to unblock a plain-HTTP app link', async () => { diff --git a/frontend/src/components/settings/ConnectDeviceDialog.tsx b/frontend/src/components/settings/ConnectDeviceDialog.tsx index cd4ed7686..63871b33c 100644 --- a/frontend/src/components/settings/ConnectDeviceDialog.tsx +++ b/frontend/src/components/settings/ConnectDeviceDialog.tsx @@ -24,9 +24,6 @@ const WARNING_MESSAGES: Record = { 'headers-unreadable': 'Stored remote access headers could not be decrypted and are not included.', } -const LOOPBACK_EXPLANATION = - 'This page is open on localhost, which your phone cannot reach. Open the Manager at its public or LAN address to show the app QR code.' - const INSECURE_ORIGIN_EXPLANATION = "The mobile app QR code needs HTTPS. Use an HTTPS address, or turn on 'Allow plain-HTTP app links on private networks' if this Manager uses a private IP address." @@ -187,11 +184,7 @@ export function ConnectDeviceDialog({ open, onOpenChange }: ConnectDeviceDialogP

) : ( -

- {links.appLinkBlockedReason === 'loopback' - ? LOOPBACK_EXPLANATION - : INSECURE_ORIGIN_EXPLANATION} -

+

{INSECURE_ORIGIN_EXPLANATION}

)} diff --git a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx index 6690d2f49..92c9f52fe 100644 --- a/frontend/src/components/settings/ConnectDeviceSettings.test.tsx +++ b/frontend/src/components/settings/ConnectDeviceSettings.test.tsx @@ -1,15 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { DEFAULT_USER_PREFERENCES } from '@opencode-manager/shared/schemas' import { createUseSettingsMock } from '@/test/test-utils' import { ConnectDeviceSettings } from './ConnectDeviceSettings' +const UNAVAILABLE_MESSAGE = + 'This page is open on localhost, which other devices cannot reach. Open the Manager at its public or LAN address to pair a device.' + const mocks = vi.hoisted(() => ({ useSettings: vi.fn(), updateSettings: vi.fn(), createDevicePairingLinks: vi.fn(), + getDevicePairingAvailability: vi.fn(), })) vi.mock('@/hooks/useSettings', () => ({ @@ -18,6 +22,7 @@ vi.mock('@/hooks/useSettings', () => ({ vi.mock('@/api/devicePairing', () => ({ createDevicePairingLinks: mocks.createDevicePairingLinks, + getDevicePairingAvailability: mocks.getDevicePairingAvailability, })) function renderSettings() { @@ -56,6 +61,11 @@ describe('ConnectDeviceSettings', () => { headerNames: [], warnings: [], }) + mocks.getDevicePairingAvailability.mockResolvedValue({ + available: true, + reason: null, + message: null, + }) }) it('persists the private-network plain-HTTP preference', async () => { @@ -75,8 +85,45 @@ describe('ConnectDeviceSettings', () => { const user = userEvent.setup() renderSettings() - await user.click(screen.getByRole('button', { name: 'Show QR code' })) + const button = screen.getByRole('button', { name: 'Show QR code' }) + await waitFor(() => expect(button).toBeEnabled()) + await user.click(button) expect(await screen.findByAltText('Mobile app pairing QR code')).toBeInTheDocument() }) + + it('disables the QR code button while availability loads', async () => { + mocks.getDevicePairingAvailability.mockReturnValue(new Promise(() => undefined)) + renderSettings() + + expect(screen.getByRole('button', { name: 'Show QR code' })).toBeDisabled() + }) + + it('disables the QR code button and shows the reason when pairing is unavailable', async () => { + const user = userEvent.setup() + mocks.getDevicePairingAvailability.mockResolvedValue({ + available: false, + reason: 'loopback', + message: UNAVAILABLE_MESSAGE, + }) + renderSettings() + + const button = screen.getByRole('button', { name: 'Show QR code' }) + await screen.findByText(UNAVAILABLE_MESSAGE) + expect(button).toBeDisabled() + + await user.click(button) + + expect(screen.queryByAltText('Mobile app pairing QR code')).not.toBeInTheDocument() + expect(mocks.createDevicePairingLinks).not.toHaveBeenCalled() + }) + + it('keeps the QR code button enabled when the availability check fails', async () => { + mocks.getDevicePairingAvailability.mockRejectedValue(new Error('availability failed')) + renderSettings() + + const button = screen.getByRole('button', { name: 'Show QR code' }) + await waitFor(() => expect(button).toBeEnabled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/settings/ConnectDeviceSettings.tsx b/frontend/src/components/settings/ConnectDeviceSettings.tsx index 80624a7d5..58e8b195e 100644 --- a/frontend/src/components/settings/ConnectDeviceSettings.tsx +++ b/frontend/src/components/settings/ConnectDeviceSettings.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' -import { QrCode } from 'lucide-react' +import { AlertCircle, QrCode } from 'lucide-react' +import { useDevicePairingAvailability } from '@/hooks/useDevicePairing' import { useSettings } from '@/hooks/useSettings' +import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' @@ -12,7 +14,11 @@ const ALLOW_PRIVATE_NETWORK_HTTP_LABEL = 'Allow plain-HTTP app links on private export function ConnectDeviceSettings() { const { preferences, updateSettings } = useSettings() const [dialogOpen, setDialogOpen] = useState(false) + const availability = useDevicePairingAvailability() const allowPrivateNetworkHttp = preferences?.devicePairing?.allowPrivateNetworkHttp ?? false + const isPairingAvailable = availability.data?.available !== false + const unavailableMessage = + availability.data?.available === false ? availability.data.message : null return ( @@ -26,10 +32,22 @@ export function ConnectDeviceSettings() { - + {unavailableMessage && ( + + + {unavailableMessage} + + )} +