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:
parent
91b1ad1a44
commit
1f36636e06
|
|
@ -28,7 +28,8 @@ import type {
|
||||||
ExtensionAPI,
|
ExtensionAPI,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} 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 {
|
import {
|
||||||
generateSessionId,
|
generateSessionId,
|
||||||
loadOrCreateToken,
|
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 {
|
function isAuthenticated(req: IncomingMessage): boolean {
|
||||||
// Check session cookie first
|
// 1. Session cookie (legacy browser HTML client)
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
const sessionId = cookies[SESSION_COOKIE];
|
const sessionId = cookies[SESSION_COOKIE];
|
||||||
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined;
|
||||||
if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true;
|
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 url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const providedToken = url.searchParams.get("token");
|
const providedToken = url.searchParams.get("token");
|
||||||
if (providedToken && validateToken(providedToken, token)) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,12 +226,34 @@ export async function startServer(
|
||||||
|
|
||||||
// New API routes (IC-2) — bearer token auth
|
// New API routes (IC-2) — bearer token auth
|
||||||
const asyncHandler = async (): Promise<boolean> => {
|
const asyncHandler = async (): Promise<boolean> => {
|
||||||
// Auth check for API routes
|
// POST /pair — unauthenticated, uses one-time pairingToken
|
||||||
const bearer = extractBearer(req);
|
if (pathname === "/pair" && req.method === "POST") {
|
||||||
const isApiAuthed =
|
let body: Record<string, unknown> = {};
|
||||||
(bearer && validateToken(bearer, token)) ||
|
try {
|
||||||
(bearer ? !!(await validateBearer(bearer).catch(() => null)) : false);
|
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;
|
if (!isApiAuthed) return false;
|
||||||
|
|
||||||
// GET /health
|
// GET /health
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue