From 0f899477e463f4427a21b38a1febb01875f57539 Mon Sep 17 00:00:00 2001 From: Eric Chan Date: Fri, 8 May 2026 09:59:35 +0800 Subject: [PATCH] fix(auth): allow bootstrap without password Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/api/settings/require-login/route.ts | 2 +- src/app/login/page.tsx | 239 ++++++++++---------- src/shared/constants/publicApiRoutes.ts | 6 +- src/shared/utils/apiAuth.ts | 8 + tests/unit/authz/pipeline.test.ts | 34 +++ tests/unit/login-bootstrap-route.test.ts | 24 ++ tests/unit/public-api-routes.test.ts | 4 +- 7 files changed, 192 insertions(+), 125 deletions(-) diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 0788eaaa0cce..b931724ae734 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -20,7 +20,7 @@ function hasConfiguredPassword(settings: Record) { } function isBootstrapSecurityWindow(settings: Record) { - return settings.setupComplete !== true && !hasConfiguredPassword(settings); + return !hasConfiguredPassword(settings); } export async function GET() { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index edd0f8228a40..0ab5f66353b3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -87,34 +87,37 @@ export default function LoginPage() { } }; - const nodeWarningBanner = !nodeCompatible && nodeVersion ? ( -
-
-
-
- error -
-
-

{t("nodeIncompatibleTitle")}

-

- {t("nodeIncompatibleDesc", { version: nodeVersion })} -

-
-
- terminal - {t("nodeIncompatibleFixLabel")} + const nodeWarningBanner = + !nodeCompatible && nodeVersion ? ( +
+
+
+
+ error +
+
+

+ {t("nodeIncompatibleTitle")} +

+

+ {t("nodeIncompatibleDesc", { version: nodeVersion })} +

+
+
+ terminal + {t("nodeIncompatibleFixLabel")} +
+ nvm install 22 && nvm use 22
- nvm install 22 && nvm use 22 +

+ info + {t("nodeIncompatibleHint")} +

-

- info - {t("nodeIncompatibleHint")} -

-
- ) : null; + ) : null; if (hasPassword === null || setupComplete === null) { return ( @@ -194,7 +197,7 @@ export default function LoginPage() { @@ -212,116 +215,116 @@ export default function LoginPage() { return (
{nodeWarningBanner && ( -
- {nodeWarningBanner} -
+
{nodeWarningBanner}
)}
-
-
-
-
-
- hub +
+
+
+
+
+ hub +
+ + OmniRoute +
- OmniRoute +

{t("signIn")}

+

{t("enterPassword")}

-

{t("signIn")}

-

{t("enterPassword")}

-
-
-
- - setPassword(e.target.value)} - required - autoFocus - className="h-11" - /> - {error && ( -

- error - {error} -

- )} -

{t("defaultPasswordHint")}

-
+ +
+ + setPassword(e.target.value)} + required + autoFocus + className="h-11" + /> + {error && ( +

+ error + {error} +

+ )} +

{t("defaultPasswordHint")}

+
- -
+ + -
-
-
-
-
-
-

{t("unifiedAiApiProxy")}

-

{t("unifiedAiApiProxyDesc")}

-
+
+
+
+
+

{t("unifiedAiApiProxy")}

+

{t("unifiedAiApiProxyDesc")}

+
-
- {[ - { - icon: "swap_horiz", - title: t("featureMultiProviderTitle"), - desc: t("featureMultiProviderDesc"), - }, - { - icon: "speed", - title: t("featureLoadBalancingTitle"), - desc: t("featureLoadBalancingDesc"), - }, - { - icon: "analytics", - title: t("featureUsageTrackingTitle"), - desc: t("featureUsageTrackingDesc"), - }, - ].map((item) => ( -
-
- - {item.icon} - -
-
-

{item.title}

-

{item.desc}

+
+ {[ + { + icon: "swap_horiz", + title: t("featureMultiProviderTitle"), + desc: t("featureMultiProviderDesc"), + }, + { + icon: "speed", + title: t("featureLoadBalancingTitle"), + desc: t("featureLoadBalancingDesc"), + }, + { + icon: "analytics", + title: t("featureUsageTrackingTitle"), + desc: t("featureUsageTrackingDesc"), + }, + ].map((item) => ( +
+
+ + {item.icon} + +
+
+

{item.title}

+

{item.desc}

+
-
- ))} + ))} +
-
); } diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 8e12f6dbd9ff..4a6e41fd3545 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -3,16 +3,14 @@ const PUBLIC_API_ROUTE_PREFIXES = [ "/api/auth/logout", "/api/auth/status", "/api/init", + "/api/settings/require-login", "/api/v1/", "/api/cloud/", "/api/sync/bundle", "/api/oauth/", ]; -const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ - "/api/monitoring/health", - "/api/settings/require-login", -]; +const PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health"]; const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 906179830baf..f4750750dbc8 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -54,6 +54,10 @@ function getRequestPathname(request: RequestLike | Request | null | undefined): } } +function isOnboardingBootstrapPath(pathname: string | null): boolean { + return pathname === "/dashboard/onboarding"; +} + function getRequestMethod(request: RequestLike | Request | null | undefined): string { if ( request && @@ -273,6 +277,10 @@ export async function isAuthRequired( if (!request) return false; const pathname = getRequestPathname(request); + if (isOnboardingBootstrapPath(pathname)) { + return false; + } + if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) { return false; } diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index a2ee225445c8..a0e52d4d60f3 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -84,6 +84,40 @@ test("runAuthzPipeline redirects unauthenticated dashboard pages to login", asyn assert.ok(response.headers.get("x-request-id")); }); +test("runAuthzPipeline allows onboarding when login is required but no password exists", async () => { + delete process.env.INITIAL_PASSWORD; + await settingsDb.updateSettings({ + requireLogin: true, + setupComplete: true, + password: "", + }); + + const response = await pipeline.runAuthzPipeline( + request("https://example.com/dashboard/onboarding"), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + +test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => { + delete process.env.INITIAL_PASSWORD; + await settingsDb.updateSettings({ + requireLogin: true, + setupComplete: true, + password: "", + }); + + const response = await pipeline.runAuthzPipeline( + request("https://example.com/api/settings/require-login", { method: "POST" }), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC"); +}); + test("runAuthzPipeline keeps management API rejections as JSON", async () => { await forceAuthRequired(); diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index 3ffa4f5e6e65..56fdbc3ed0aa 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -184,6 +184,30 @@ test("login bootstrap route POST rejects unauthenticated writes after setup is c assert.equal(settings.requireLogin, true); }); +test("login bootstrap route POST allows first password creation after setup completed without a password", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "", + setupComplete: true, + }); + + const request = new Request("http://localhost/api/settings/require-login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requireLogin: true, password: "first-secret" }), + }); + + const response = await route.POST(request); + const body = (await response.json()) as any; + const settings = await settingsDb.getSettings(); + + assert.equal(response.status, 200); + assert.deepEqual(body, { success: true }); + assert.equal(settings.requireLogin, true); + assert.ok(settings.password); + assert.equal(await bcrypt.compare("first-secret", settings.password), true); +}); + test("public login bootstrap route POST returns 500 when hashing fails", async () => { bcrypt.hash = async () => { throw new Error("hash failed"); diff --git a/tests/unit/public-api-routes.test.ts b/tests/unit/public-api-routes.test.ts index fa8e7202d367..755cec0cbb9e 100644 --- a/tests/unit/public-api-routes.test.ts +++ b/tests/unit/public-api-routes.test.ts @@ -9,7 +9,7 @@ test("isPublicApiRoute allows public management prefixes", () => { assert.equal(isPublicApiRoute("/api/oauth/cursor/callback"), true); }); -test("isPublicApiRoute allows readonly bootstrap route only for safe methods", () => { +test("isPublicApiRoute allows readonly health and require-login bootstrap routes", () => { assert.equal(isPublicApiRoute("/api/monitoring/health", "GET"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "HEAD"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "OPTIONS"), true); @@ -18,7 +18,7 @@ test("isPublicApiRoute allows readonly bootstrap route only for safe methods", ( assert.equal(isPublicApiRoute("/api/settings/require-login", "GET"), true); assert.equal(isPublicApiRoute("/api/settings/require-login", "HEAD"), true); assert.equal(isPublicApiRoute("/api/settings/require-login", "OPTIONS"), true); - assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), false); + assert.equal(isPublicApiRoute("/api/settings/require-login", "POST"), true); }); test("isPublicApiRoute rejects non-public management routes", () => {