feat(volcengine): phone/SMS auto-login for console with MFA + identity selection

- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
This commit is contained in:
deploy
2026-08-21 21:50:18 +08:00
committed by Markus Hartung
parent 07a378c86c
commit 8f15b79a84
12 changed files with 2648 additions and 3 deletions

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/cancel
* Cancel an auto phone login session and close its headless browser.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.cancel(sessionId);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, session });
} catch {
return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/code
* Submit the SMS verification code (plus image captcha when required) for an
* auto phone login session. Returns the session view; binding runs lazily on
* the next status poll once credentials are extracted.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
const body = await request.json().catch(() => ({}));
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const session = await volcengineConsoleAutoLoginService.submitCode(
sessionId,
String(body.code ?? ""),
typeof body.captcha === "string" ? body.captcha : undefined,
{ timeout }
);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
// Credentials ready → bind immediately so the response carries the outcome.
if (session.phase === "success") {
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
return NextResponse.json({ success: true, session: bound ?? session });
}
return NextResponse.json({ success: false, session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano code submission failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/identity
* Pick an identity on the console's select_identity page (the phone maps to
* multiple accounts) and finish the login + plan binding.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
const body = await request.json().catch(() => ({}));
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
const index = Number(body.index);
if (!Number.isInteger(index) || index < 0) {
return NextResponse.json(
{ success: false, error: "Invalid identity index" },
{ status: 400 }
);
}
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, {
timeout,
});
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
// Credentials ready → bind immediately so the response carries the outcome.
if (session.phase === "success") {
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
return NextResponse.json({ success: true, session: bound ?? session });
}
return NextResponse.json({ success: false, session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano identity selection failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/resend
* Re-trigger the SMS verification code for an active login session.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.resendCode(sessionId);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, session });
} catch {
return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* GET /api/providers/volcengine-plan/connect/[sessionId]/status
* Poll an auto phone login session. When credentials have been extracted, the
* plan binding runs lazily (deduped) and its result is attached to the view.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: session.phase === "success", session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano login status failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -10,6 +10,26 @@ export async function POST(request: Request): Promise<NextResponse> {
const body = await request.json().catch(() => ({}));
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
// Auto flow: phone present → start a session-based headless phone/SMS login.
if (typeof body.phone === "string" && body.phone.trim()) {
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout });
if (!started.ok) {
return NextResponse.json({ success: false, error: started.error }, { status: 400 });
}
return NextResponse.json({ success: true, session: started.session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano auto login failed to start: ${message}` },
{ status: 500 }
);
}
}
// Legacy manual flow: headful browser login on the server machine.
try {
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
const login = await inAppLoginService.startLogin("volcengine-console", { timeout });