From 1f36636e06dccd17ae4c08fc715c08bb5da988d8 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 16 May 2026 02:46:15 +0200 Subject: [PATCH] feat: POST /pair endpoint + async bearer token auth - POST /pair: consumes one-time pairingToken, creates named bearer token, returns { bearerToken, sidecarId } per IC-3 - isAuthenticatedAsync(): checks legacy token + new multi-token store - isAuthenticated(): extended with Bearer header support for WS upgrade - Smoke still 12/12 green --- extensions/remote-control/server/server.ts | 62 ++++++++++++++++++---- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/extensions/remote-control/server/server.ts b/extensions/remote-control/server/server.ts index db58aa6..a04c93e 100644 --- a/extensions/remote-control/server/server.ts +++ b/extensions/remote-control/server/server.ts @@ -28,7 +28,8 @@ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; -import { validateBearer } from "../auth/tokens.js"; +import { consumePairingToken } from "../auth/pairing.js"; +import { createToken, validateBearer } from "../auth/tokens.js"; import { generateSessionId, loadOrCreateToken, @@ -87,19 +88,40 @@ export async function startServer( } }; - /** Check if a request is authenticated (valid token query param OR valid session cookie) */ + /** Check if a request is authenticated. + * Accepts: session cookie | legacy ?token= | Authorization: Bearer + */ function isAuthenticated(req: IncomingMessage): boolean { - // Check session cookie first + // 1. Session cookie (legacy browser HTML client) const cookies = parseCookies(req.headers.cookie); const sessionId = cookies[SESSION_COOKIE]; const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined; if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true; - // Check token query param + // 2. Legacy single token via query param (smoke tests, CLI) const url = new URL(req.url ?? "/", "http://localhost"); const providedToken = url.searchParams.get("token"); if (providedToken && validateToken(providedToken, token)) return true; + // 3. Bearer token via Authorization header or ?token= (iOS app, multi-token) + const bearer = extractBearer(req); + if (bearer) { + if (validateToken(bearer, token)) return true; + // async validateBearer checked in asyncHandler for API routes; + // for WS upgrade we do a sync fallback: legacy token only here. + } + + return false; + } + + /** Async auth check — also validates multi-token bearer from auth/tokens store */ + async function isAuthenticatedAsync(req: IncomingMessage): Promise { + if (isAuthenticated(req)) return true; + const bearer = extractBearer(req); + if (bearer) { + const entry = await validateBearer(bearer).catch(() => null); + if (entry) return true; + } return false; } @@ -204,12 +226,34 @@ export async function startServer( // New API routes (IC-2) — bearer token auth const asyncHandler = async (): Promise => { - // Auth check for API routes - const bearer = extractBearer(req); - const isApiAuthed = - (bearer && validateToken(bearer, token)) || - (bearer ? !!(await validateBearer(bearer).catch(() => null)) : false); + // POST /pair — unauthenticated, uses one-time pairingToken + if (pathname === "/pair" && req.method === "POST") { + let body: Record = {}; + try { + const raw = await readBody(req); + if (raw.trim()) body = JSON.parse(raw) as Record; + } catch { + sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" }); + return true; + } + const pairingToken = + typeof body.pairingToken === "string" ? body.pairingToken : null; + if (!pairingToken || !consumePairingToken(pairingToken)) { + sendJson(res, 403, { + error: "invalid_pairing_token", + message: "Pairing token invalid or expired", + }); + return true; + } + const deviceName = + typeof body.deviceName === "string" ? body.deviceName : "iOS device"; + const entry = await createToken(deviceName); + sendJson(res, 200, { bearerToken: entry.token, sidecarId: entry.id }); + return true; + } + // All other API routes require auth + const isApiAuthed = await isAuthenticatedAsync(req); if (!isApiAuthed) return false; // GET /health