diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 3d16662..54b9abd 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -4655,6 +4655,7 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st const dc = generateShortCode(16); const uc = generateShortCode(4) + "-" + generateShortCode(4); + const sid = "clm_sess_" + generateShortCode(32); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown"; @@ -4662,6 +4663,7 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st await db.insert(deviceCodeTable).values({ deviceCode: dc, userCode: uc, + sessionId: sid, hostname: body.hostname, platform: body.platform, arch: body.arch, @@ -4674,10 +4676,12 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st writeJson(res, 200, { device_code: dc, user_code: uc, + session_id: sid, expires_at: expiresAt.toISOString(), verification_url: `${baseUrl}/cli-auth`, + token_url: `${baseUrl}/token`, }); - log.info("device-code", { route: "POST /cli/device-code", user_code: uc, latency_ms: Date.now() - started }); + log.info("device-code", { route: "POST /cli/device-code", user_code: uc, session_id: sid, latency_ms: Date.now() - started }); } catch (e) { log.error("device-code", { error: e instanceof Error ? e.message : String(e) }); writeJson(res, 500, { error: "Failed to create device code" }); @@ -4745,10 +4749,15 @@ async function handleDeviceCodeApprove(req: IncomingMessage, code: string, res: } try { - // Find device code by user_code (browser sends user_code, not device_code) - const [entry] = await db.select().from(deviceCodeTable) - .where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending"))) + // Find by session_id first (URL param), fall back to user_code (legacy) + let [entry] = await db.select().from(deviceCodeTable) + .where(and(eq(deviceCodeTable.sessionId, code), eq(deviceCodeTable.status, "pending"))) .limit(1); + if (!entry) { + [entry] = await db.select().from(deviceCodeTable) + .where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending"))) + .limit(1); + } if (!entry) { writeJson(res, 404, { error: "Code not found or expired" }); diff --git a/apps/web/src/app/[locale]/cli-auth/cli-auth-login.tsx b/apps/web/src/app/[locale]/cli-auth/cli-auth-login.tsx index f285af8..d0f8d81 100644 --- a/apps/web/src/app/[locale]/cli-auth/cli-auth-login.tsx +++ b/apps/web/src/app/[locale]/cli-auth/cli-auth-login.tsx @@ -8,7 +8,8 @@ interface Props { } export function CliAuthLogin({ code }: Props) { - const redirectTo = `/cli-auth?code=${encodeURIComponent(code)}`; + const param = code.startsWith("clm_sess_") ? "session" : "code"; + const redirectTo = `/cli-auth?${param}=${encodeURIComponent(code)}`; const [loading, setLoading] = useState(null); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); diff --git a/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx b/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx index dd4ad93..3a360e3 100644 --- a/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx +++ b/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx @@ -21,7 +21,7 @@ export function DeviceCodeApproval({ code, userName }: Props) { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ user_code: code }), + body: JSON.stringify({ session_id: code }), }) .then(async (res) => { if (res.ok) { diff --git a/apps/web/src/app/[locale]/cli-auth/page.tsx b/apps/web/src/app/[locale]/cli-auth/page.tsx index ba38785..6a47f53 100644 --- a/apps/web/src/app/[locale]/cli-auth/page.tsx +++ b/apps/web/src/app/[locale]/cli-auth/page.tsx @@ -13,29 +13,30 @@ export const generateMetadata = getMetadata({ export default async function CliAuthPage({ searchParams, }: { - searchParams: Promise<{ code?: string; port?: string }>; + searchParams: Promise<{ code?: string; session?: string; port?: string }>; }) { const { user } = await getSession(); - const { code, port } = await searchParams; + const { code, session, port } = await searchParams; - // Device-code flow: code contains "-" (e.g. "ABCD-EFGH"), no port - const isDeviceCode = code && code.includes("-") && !port; + // New 3-token flow: ?session=clm_sess_... (session_id in URL) + // Legacy flow: ?code=ABCD-EFGH (user_code in URL) + const sessionId = session ?? (code && code.startsWith("clm_sess_") ? code : null); + const isDeviceCode = sessionId || (code && code.includes("-") && !port); + const approvalCode = sessionId ?? code; - if (isDeviceCode) { + if (isDeviceCode && approvalCode) { if (!user) { - // NOT logged in → show inline auth form with device code context return (
- +
); } - // Logged in → auto-approve return (
@@ -66,4 +67,3 @@ export default async function CliAuthPage({ ); } - diff --git a/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts b/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts index 68e764f..590ce37 100644 --- a/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts +++ b/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts @@ -13,19 +13,20 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } - let body: { user_code?: string }; + let body: { user_code?: string; session_id?: string }; try { body = (await request.json()) as typeof body; } catch { return NextResponse.json({ error: "Invalid body" }, { status: 400 }); } - if (!body.user_code) { - return NextResponse.json({ error: "user_code required" }, { status: 400 }); + const code = body.session_id ?? body.user_code; + if (!code) { + return NextResponse.json({ error: "session_id or user_code required" }, { status: 400 }); } // Proxy approve to the broker - const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${body.user_code}/approve`, { + const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${code}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index b6d09b7..8dcfc58 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -1125,10 +1125,12 @@ export const deviceCodeStatusEnum = meshSchema.enum("device_code_status", [ */ export const deviceCode = meshSchema.table("device_code", { id: text().primaryKey().notNull().$defaultFn(generateId), - /** Random 16-char code used by CLI to poll. */ + /** Random 16-char code used by CLI to poll (secret, never shown to user). */ deviceCode: text().notNull().unique(), - /** Human-readable code shown in browser (ABCD-EFGH). */ + /** Human-readable code shown in both terminal and browser for visual confirmation. */ userCode: text().notNull(), + /** URL-safe session identifier (clm_sess_..., 32 chars). Not secret — appears in browser URL. */ + sessionId: text().notNull().unique(), status: deviceCodeStatusEnum().notNull().default("pending"), /** Filled on approve — the authenticated user. */ userId: text().references(() => user.id, { onDelete: "cascade" }),