feat: three-token auth flow (session_id + user_code + device_code)
- session_id (clm_sess_...) in browser URL — identifies login attempt - user_code (ABCD-EFGH) visual confirmation — shown in both terminal and browser - device_code (secret) — CLI polls with this, never displayed - CLI accepts stdin paste of JWT token while polling (race) - Web page handles both ?session= and ?code= params Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4655,6 +4655,7 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st
|
|||||||
|
|
||||||
const dc = generateShortCode(16);
|
const dc = generateShortCode(16);
|
||||||
const uc = generateShortCode(4) + "-" + generateShortCode(4);
|
const uc = generateShortCode(4) + "-" + generateShortCode(4);
|
||||||
|
const sid = "clm_sess_" + generateShortCode(32);
|
||||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
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";
|
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({
|
await db.insert(deviceCodeTable).values({
|
||||||
deviceCode: dc,
|
deviceCode: dc,
|
||||||
userCode: uc,
|
userCode: uc,
|
||||||
|
sessionId: sid,
|
||||||
hostname: body.hostname,
|
hostname: body.hostname,
|
||||||
platform: body.platform,
|
platform: body.platform,
|
||||||
arch: body.arch,
|
arch: body.arch,
|
||||||
@@ -4674,10 +4676,12 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st
|
|||||||
writeJson(res, 200, {
|
writeJson(res, 200, {
|
||||||
device_code: dc,
|
device_code: dc,
|
||||||
user_code: uc,
|
user_code: uc,
|
||||||
|
session_id: sid,
|
||||||
expires_at: expiresAt.toISOString(),
|
expires_at: expiresAt.toISOString(),
|
||||||
verification_url: `${baseUrl}/cli-auth`,
|
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) {
|
} catch (e) {
|
||||||
log.error("device-code", { error: e instanceof Error ? e.message : String(e) });
|
log.error("device-code", { error: e instanceof Error ? e.message : String(e) });
|
||||||
writeJson(res, 500, { error: "Failed to create device code" });
|
writeJson(res, 500, { error: "Failed to create device code" });
|
||||||
@@ -4745,10 +4749,15 @@ async function handleDeviceCodeApprove(req: IncomingMessage, code: string, res:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find device code by user_code (browser sends user_code, not device_code)
|
// Find by session_id first (URL param), fall back to user_code (legacy)
|
||||||
const [entry] = await db.select().from(deviceCodeTable)
|
let [entry] = await db.select().from(deviceCodeTable)
|
||||||
.where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending")))
|
.where(and(eq(deviceCodeTable.sessionId, code), eq(deviceCodeTable.status, "pending")))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
if (!entry) {
|
||||||
|
[entry] = await db.select().from(deviceCodeTable)
|
||||||
|
.where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending")))
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
writeJson(res, 404, { error: "Code not found or expired" });
|
writeJson(res, 404, { error: "Code not found or expired" });
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CliAuthLogin({ code }: 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<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function DeviceCodeApproval({ code, userName }: Props) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ user_code: code }),
|
body: JSON.stringify({ session_id: code }),
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -13,29 +13,30 @@ export const generateMetadata = getMetadata({
|
|||||||
export default async function CliAuthPage({
|
export default async function CliAuthPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ code?: string; port?: string }>;
|
searchParams: Promise<{ code?: string; session?: string; port?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { user } = await getSession();
|
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
|
// New 3-token flow: ?session=clm_sess_... (session_id in URL)
|
||||||
const isDeviceCode = code && code.includes("-") && !port;
|
// 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) {
|
if (!user) {
|
||||||
// NOT logged in → show inline auth form with device code context
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
||||||
<CliAuthLogin code={code} />
|
<CliAuthLogin code={approvalCode} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in → auto-approve
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
||||||
<DeviceCodeApproval
|
<DeviceCodeApproval
|
||||||
code={code}
|
code={approvalCode}
|
||||||
userName={user.name ?? user.email}
|
userName={user.name ?? user.email}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -66,4 +67,3 @@ export default async function CliAuthPage({
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,19 +13,20 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { user_code?: string };
|
let body: { user_code?: string; session_id?: string };
|
||||||
try {
|
try {
|
||||||
body = (await request.json()) as typeof body;
|
body = (await request.json()) as typeof body;
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.user_code) {
|
const code = body.session_id ?? body.user_code;
|
||||||
return NextResponse.json({ error: "user_code required" }, { status: 400 });
|
if (!code) {
|
||||||
|
return NextResponse.json({ error: "session_id or user_code required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy approve to the broker
|
// 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1125,10 +1125,12 @@ export const deviceCodeStatusEnum = meshSchema.enum("device_code_status", [
|
|||||||
*/
|
*/
|
||||||
export const deviceCode = meshSchema.table("device_code", {
|
export const deviceCode = meshSchema.table("device_code", {
|
||||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
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(),
|
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(),
|
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"),
|
status: deviceCodeStatusEnum().notNull().default("pending"),
|
||||||
/** Filled on approve — the authenticated user. */
|
/** Filled on approve — the authenticated user. */
|
||||||
userId: text().references(() => user.id, { onDelete: "cascade" }),
|
userId: text().references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user