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
This commit is contained in:
Johannes Merz 2026-05-16 02:46:15 +02:00
parent 91b1ad1a44
commit 1f36636e06
1 changed files with 53 additions and 9 deletions

View File

@ -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 <multi-token>
*/
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<boolean> {
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<boolean> => {
// 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<string, unknown> = {};
try {
const raw = await readBody(req);
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
} 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