You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/06/01 07:22:41 UTC

(texera) branch gh-readonly-queue/main/pr-5305-0e5686ec2fff54a9b1a9b44840c5de3ceb7dd178 created (now eb287f3fc2)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5305-0e5686ec2fff54a9b1a9b44840c5de3ceb7dd178
in repository https://gitbox.apache.org/repos/asf/texera.git


      at eb287f3fc2 feat(config-service): split /config/pre-login from authenticated endpoints (#5305)

This branch includes the following new commits:

     new eb287f3fc2 feat(config-service): split /config/pre-login from authenticated endpoints (#5305)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(texera) 01/01: feat(config-service): split /config/pre-login from authenticated endpoints (#5305)

Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5305-0e5686ec2fff54a9b1a9b44840c5de3ceb7dd178
in repository https://gitbox.apache.org/repos/asf/texera.git

commit eb287f3fc2cbba5416d851078affc10537123e68
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Mon Jun 1 00:22:31 2026 -0700

    feat(config-service): split /config/pre-login from authenticated endpoints (#5305)
    
    ### What changes were proposed in this PR?
    
    `config-service` no longer broadcasts the full GUI configuration to
    anonymous callers. The four fields the frontend actually needs before
    login (`localLogin`, `googleLogin`, `defaultLocalUser`,
    `attributionEnabled`) move to a new `GET /api/config/pre-login`
    (`@PermitAll`). `GET /api/config/gui` and `GET /api/config/user-system`
    are now `@RolesAllowed("REGULAR", "ADMIN")` and only answer
    authenticated traffic.
    
    On the frontend, `GuiConfigService.load()` always fetches
    `/config/pre-login` at `APP_INITIALIZER`. When a JWT is already in
    `localStorage` (browser reload while logged in), it chains `/config/gui`
    + `/config/user-system` in the same await so the full config is in
    memory before any post-login component mounts.
    `UserService.handleAccessToken` does the same chaining on a fresh login
    so `loginWithExistingToken` (which reads `config.env.inviteOnly`) runs
    only after the authenticated config has resolved. Expired-token 403s on
    the post-login fetch are caught so a stale `localStorage` token cannot
    block bootstrap — that was the exact failure mode that caused #5025 to
    revert the earlier eager-401 lockdown.
    
    ### Any related issues, documentation, discussions?
    
    Closes #5304.
    
    Related: #4901 (eager-401 from `JwtAuthFilter`), #5025 (revert that
    broke `ConfigService` bootstrap last time we attempted a similar
    lockdown), #5199 (re-applied `@RolesAllowed` enforcement on the
    microservices, with `@PermitAll` opt-out for the two pre-login
    endpoints).
    
    ### How was this PR tested?
    
    Added unit tests covering pre-login / gui / user-system access with and
    without a valid Bearer token, the orchestrator's branching on a stored
    token, the 403 fallback, and the `UserService.handleAccessToken`
    ordering guarantee.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.7
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 .../texera/service/resource/ConfigResource.scala   |  33 ++--
 .../service/resource/ConfigResourceAuthSpec.scala  | 114 +++++++++---
 .../app/common/service/gui-config.service.mock.ts  |   4 +
 .../app/common/service/gui-config.service.spec.ts  | 194 +++++++++++++++++++++
 .../src/app/common/service/gui-config.service.ts   |  86 ++++++---
 .../app/common/service/user/user.service.spec.ts   |  62 +++++++
 .../src/app/common/service/user/user.service.ts    |  29 ++-
 7 files changed, 457 insertions(+), 65 deletions(-)

diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index ace8e8618d..1db52e2dc8 100644
--- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -19,7 +19,7 @@
 
 package org.apache.texera.service.resource
 
-import jakarta.annotation.security.PermitAll
+import jakarta.annotation.security.{PermitAll, RolesAllowed}
 import jakarta.ws.rs.core.MediaType
 import jakarta.ws.rs.{GET, Path, Produces}
 import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, UserSystemConfig}
@@ -28,12 +28,26 @@ import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, Use
 @Produces(Array(MediaType.APPLICATION_JSON))
 class ConfigResource {
 
-  // These two endpoints are fetched by the frontend during app initialization,
-  // before any login, so they must answer unauthenticated callers — hence @PermitAll.
-  // They are the only endpoints in this resource, so role enforcement gates nothing
-  // here; @PermitAll is what keeps them reachable when role enforcement is enabled.
+  // Anonymous endpoint loaded by the frontend's APP_INITIALIZER before any user has
+  // logged in. Only fields that the login page (or the logged-out branches of the
+  // dashboard shell) actually need belong here — anything else lives on /gui or
+  // /user-system, both of which require authentication.
   @GET
   @PermitAll
+  @Path("/pre-login")
+  def getPreLoginConfig: Map[String, Any] =
+    Map(
+      "localLogin" -> GuiConfig.guiLoginLocalLogin,
+      "googleLogin" -> GuiConfig.guiLoginGoogleLogin,
+      "defaultLocalUser" -> Map(
+        "username" -> GuiConfig.guiLoginDefaultLocalUserUsername,
+        "password" -> GuiConfig.guiLoginDefaultLocalUserPassword
+      ),
+      "attributionEnabled" -> GuiConfig.guiAttributionEnabled
+    )
+
+  @GET
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
   @Path("/gui")
   def getGuiConfig: Map[String, Any] =
     Map(
@@ -41,8 +55,6 @@ class ConfigResource {
       "exportExecutionResultEnabled" -> GuiConfig.guiWorkflowWorkspaceExportExecutionResultEnabled,
       "autoAttributeCorrectionEnabled" -> GuiConfig.guiWorkflowWorkspaceAutoAttributeCorrectionEnabled,
       "selectingFilesFromDatasetsEnabled" -> GuiConfig.guiWorkflowWorkspaceSelectingFilesFromDatasetsEnabled,
-      "localLogin" -> GuiConfig.guiLoginLocalLogin,
-      "googleLogin" -> GuiConfig.guiLoginGoogleLogin,
       "userPresetEnabled" -> GuiConfig.guiWorkflowWorkspaceUserPresetEnabled,
       "workflowExecutionsTrackingEnabled" -> GuiConfig.guiWorkflowWorkspaceWorkflowExecutionsTrackingEnabled,
       "linkBreakpointEnabled" -> GuiConfig.guiWorkflowWorkspaceLinkBreakpointEnabled,
@@ -55,20 +67,15 @@ class ConfigResource {
       "sharingComputingUnitEnabled" -> ComputingUnitConfig.sharingComputingUnitEnabled,
       "operatorConsoleMessageBufferSize" -> GuiConfig.guiWorkflowWorkspaceOperatorConsoleMessageBufferSize,
       "pythonLanguageServerPort" -> GuiConfig.guiWorkflowWorkspacePythonLanguageServerPort,
-      "defaultLocalUser" -> Map(
-        "username" -> GuiConfig.guiLoginDefaultLocalUserUsername,
-        "password" -> GuiConfig.guiLoginDefaultLocalUserPassword
-      ),
       "activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes,
       "copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled,
       "limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns,
-      "attributionEnabled" -> GuiConfig.guiAttributionEnabled,
       // flags from the auth.conf if needed
       "expirationTimeInMinutes" -> AuthConfig.jwtExpirationMinutes
     )
 
   @GET
-  @PermitAll
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
   @Path("/user-system")
   def getUserSystemConfig: Map[String, Any] =
     Map(
diff --git a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
index 56cde8c263..3475682ef8 100644
--- a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
+++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceAuthSpec.scala
@@ -35,11 +35,12 @@ import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
 // Wires ConfigResource through the same Jersey auth pipeline production uses
-// (JwtAuthFilter + RolesAllowedDynamicFeature) and fires HTTP requests with no
-// Authorization header. Regression guard for the bootstrap break that caused
-// PR #5049 to be reverted in #5173: /config/gui and /config/user-system are
-// loaded by the frontend's APP_INITIALIZER before any login, so they must
-// return 200 to unauthenticated callers even with role enforcement enabled.
+// (JwtAuthFilter + RolesAllowedDynamicFeature) and fires HTTP requests with and
+// without an Authorization header. /config/pre-login is the only @PermitAll
+// endpoint and must answer unauthenticated callers (bootstrap regression guard,
+// same shape as the break that caused PR #5049 to be reverted in #5173).
+// /config/gui and /config/user-system are now @RolesAllowed; they must reject
+// anonymous traffic and accept callers with a valid Bearer token.
 class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAfterAll {
 
   // Mirror production's mapper: ConfigService bootstraps Dropwizard's default mapper
@@ -59,18 +60,94 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
   override protected def beforeAll(): Unit = resources.before()
   override protected def afterAll(): Unit = resources.after()
 
-  "GET /config/gui" should "return 200 without an Authorization header" in {
+  private def regularToken(): String = {
+    val u = new User()
+    u.setUid(2)
+    u.setName("test-regular")
+    u.setEmail("test-regular@example.com")
+    u.setGoogleId(null)
+    u.setRole(UserRoleEnum.REGULAR)
+    JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
+  }
+
+  private def adminToken(): String = {
+    val u = new User()
+    u.setUid(1)
+    u.setName("test-admin")
+    u.setEmail("test-admin@example.com")
+    u.setGoogleId(null)
+    u.setRole(UserRoleEnum.ADMIN)
+    JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
+  }
+
+  "GET /config/pre-login" should "return 200 without an Authorization header" in {
+    val response = resources.target("/config/pre-login").request(MediaType.APPLICATION_JSON).get()
+    response.getStatus shouldBe 200
+  }
+
+  it should "expose exactly the fields the login UI needs and nothing else" in {
+    // Locking down the payload keeps anonymous callers from reading workspace flags,
+    // feature toggles, or session timers. If a new field is needed before login, it
+    // must be added here explicitly; the assertion forces that decision into review.
+    val payload = resources
+      .target("/config/pre-login")
+      .request(MediaType.APPLICATION_JSON)
+      .get(classOf[Map[String, Any]])
+    payload.keySet shouldBe Set(
+      "localLogin",
+      "googleLogin",
+      "defaultLocalUser",
+      "attributionEnabled"
+    )
+  }
+
+  "GET /config/gui" should "return 403 without an Authorization header" in {
     val response = resources.target("/config/gui").request(MediaType.APPLICATION_JSON).get()
+    response.getStatus shouldBe 403
+  }
+
+  it should "return 200 with a valid Bearer token whose role matches @RolesAllowed" in {
+    val response = resources
+      .target("/config/gui")
+      .request(MediaType.APPLICATION_JSON)
+      .header("Authorization", s"Bearer ${regularToken()}")
+      .get()
     response.getStatus shouldBe 200
   }
 
-  "GET /config/user-system" should "return 200 without an Authorization header" in {
+  it should "not leak any pre-login field through the authenticated payload" in {
+    // The split is only meaningful if /gui drops the fields that /pre-login owns.
+    // Without this, a future refactor could re-add them under the @RolesAllowed
+    // endpoint, doubling the surface and creating two sources of truth.
+    val payload = resources
+      .target("/config/gui")
+      .request(MediaType.APPLICATION_JSON)
+      .header("Authorization", s"Bearer ${regularToken()}")
+      .get(classOf[Map[String, Any]])
+    payload.keySet should contain noneOf (
+      "localLogin",
+      "googleLogin",
+      "defaultLocalUser",
+      "attributionEnabled"
+    )
+  }
+
+  "GET /config/user-system" should "return 403 without an Authorization header" in {
     val response =
       resources.target("/config/user-system").request(MediaType.APPLICATION_JSON).get()
+    response.getStatus shouldBe 403
+  }
+
+  it should "return 200 with a valid Bearer token whose role matches @RolesAllowed" in {
+    val response = resources
+      .target("/config/user-system")
+      .request(MediaType.APPLICATION_JSON)
+      .header("Authorization", s"Bearer ${regularToken()}")
+      .get()
     response.getStatus shouldBe 200
   }
 
-  "GET an @RolesAllowed endpoint" should "return 403 without an Authorization header" in {
+  "GET an @RolesAllowed probe endpoint" should "return 403 without an Authorization header" in {
     // Sanity: with no SecurityContext set by JwtAuthFilter, RolesAllowedDynamicFeature
     // must reject. Catches the case where the feature is registered but somehow
     // disabled (e.g. swallowed exception during setup).
@@ -82,22 +159,15 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
   it should "return 200 with a valid Bearer token whose role matches @RolesAllowed" in {
     // Positive-direction sibling to the previous test. Without this, a filter-
     // priority bug that lets RolesAllowedRequestFilter run *before* JwtAuthFilter
-    // is invisible to the spec: the no-auth case still 403s, the @PermitAll cases
-    // still 200, and the only path that actually exercises auth → authz ordering
-    // is "valid JWT → 200". Manual integration testing of PR #5199 found this:
-    // a real admin JWT was getting 403 on every @RolesAllowed endpoint until
-    // JwtAuthFilter was pinned to Priorities.AUTHENTICATION.
-    val u = new User()
-    u.setUid(1)
-    u.setName("test-admin")
-    u.setEmail("test-admin@example.com")
-    u.setGoogleId(null)
-    u.setRole(UserRoleEnum.ADMIN)
-    val token = JwtAuth.jwtToken(JwtAuth.jwtClaims(u, expireInDays = 1))
+    // is invisible to the spec: the no-auth case still 403s, and the only path
+    // that actually exercises auth → authz ordering is "valid JWT → 200". Manual
+    // integration testing of PR #5199 found this: a real admin JWT was getting
+    // 403 on every @RolesAllowed endpoint until JwtAuthFilter was pinned to
+    // Priorities.AUTHENTICATION.
     val response = resources
       .target("/auth-probe")
       .request(MediaType.APPLICATION_JSON)
-      .header("Authorization", s"Bearer $token")
+      .header("Authorization", s"Bearer ${adminToken()}")
       .get()
     response.getStatus shouldBe 200
   }
@@ -106,7 +176,7 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
 object ConfigResourceAuthSpec {
   // A deliberately @RolesAllowed companion to ConfigResource, so the same setup also
   // proves the feature actually rejects when it should — a 200 on the @PermitAll
-  // endpoints would otherwise be consistent with the feature being silently no-op'd.
+  // endpoint would otherwise be consistent with the feature being silently no-op'd.
   @Path("/auth-probe")
   @Produces(Array(MediaType.APPLICATION_JSON))
   class ProtectedProbe {
diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts
index 0e20da8024..9165c19b9c 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -63,6 +63,10 @@ export class MockGuiConfigService {
     return of(this._config);
   }
 
+  loadPostLogin(): Observable<Partial<GuiConfig>> {
+    return of(this._config);
+  }
+
   setConfig(config: Partial<GuiConfig>): void {
     this._config = { ...this._config, ...config };
   }
diff --git a/frontend/src/app/common/service/gui-config.service.spec.ts b/frontend/src/app/common/service/gui-config.service.spec.ts
new file mode 100644
index 0000000000..0294ea8307
--- /dev/null
+++ b/frontend/src/app/common/service/gui-config.service.spec.ts
@@ -0,0 +1,194 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+
+import { GuiConfigService } from "./gui-config.service";
+import { AppSettings } from "../app-setting";
+
+const API = "api";
+const TOKEN_KEY = "access_token";
+
+const PRE_LOGIN_PAYLOAD = {
+  localLogin: true,
+  googleLogin: false,
+  defaultLocalUser: { username: "demo", password: "demo-pw" },
+  attributionEnabled: true,
+};
+
+const GUI_PAYLOAD = {
+  copilotEnabled: true,
+  limitColumns: 42,
+  defaultExecutionMode: "PIPELINED",
+};
+
+const USER_SYSTEM_PAYLOAD = {
+  inviteOnly: true,
+};
+
+describe("GuiConfigService", () => {
+  let service: GuiConfigService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    localStorage.removeItem(TOKEN_KEY);
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [GuiConfigService],
+    });
+    service = TestBed.inject(GuiConfigService);
+    http = TestBed.inject(HttpTestingController);
+    vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+  });
+
+  afterEach(() => {
+    localStorage.removeItem(TOKEN_KEY);
+    http.verify();
+  });
+
+  // ─── loadPreLogin ─────────────────────────────────────────────────────────
+
+  it("loadPreLogin fetches /config/pre-login and merges the response into env", async () => {
+    const pending = firstValueFrom(service.loadPreLogin());
+
+    const req = http.expectOne(`${API}/config/pre-login`);
+    expect(req.request.method).toBe("GET");
+    req.flush(PRE_LOGIN_PAYLOAD);
+
+    await pending;
+    expect(service.env.localLogin).toBe(true);
+    expect(service.env.googleLogin).toBe(false);
+    expect(service.env.defaultLocalUser).toEqual({ username: "demo", password: "demo-pw" });
+    expect(service.env.attributionEnabled).toBe(true);
+  });
+
+  it("loadPreLogin propagates errors so APP_INITIALIZER can surface them", async () => {
+    // Network failure at bootstrap means the login page can't render correctly.
+    // The orchestrator and APP_INITIALIZER both rely on this rejection to log
+    // and (in production) show an error banner, so the rejection must escape.
+    const pending = firstValueFrom(service.loadPreLogin());
+    http.expectOne(`${API}/config/pre-login`).error(new ProgressEvent("net error"));
+    await expect(pending).rejects.toThrow(/pre-login configuration/);
+  });
+
+  // ─── loadPostLogin ────────────────────────────────────────────────────────
+
+  it("loadPostLogin fetches /config/gui and /config/user-system in parallel and merges", async () => {
+    const pending = firstValueFrom(service.loadPostLogin());
+
+    const gui = http.expectOne(`${API}/config/gui`);
+    const userSystem = http.expectOne(`${API}/config/user-system`);
+    expect(gui.request.method).toBe("GET");
+    expect(userSystem.request.method).toBe("GET");
+
+    gui.flush(GUI_PAYLOAD);
+    userSystem.flush(USER_SYSTEM_PAYLOAD);
+
+    await pending;
+    expect(service.env.copilotEnabled).toBe(true);
+    expect(service.env.limitColumns).toBe(42);
+    expect(service.env.inviteOnly).toBe(true);
+  });
+
+  it("loadPostLogin preserves pre-login fields when both phases run", async () => {
+    // Guards a refactor that overwrites the merged map with the gui/user-system
+    // response shape. Without this assertion, pre-login fields could silently
+    // disappear from env after post-login completes.
+    const preLoginPending = firstValueFrom(service.loadPreLogin());
+    http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD);
+    await preLoginPending;
+
+    const postLoginPending = firstValueFrom(service.loadPostLogin());
+    http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD);
+    http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD);
+    await postLoginPending;
+
+    expect(service.env.localLogin).toBe(true);
+    expect(service.env.defaultLocalUser).toEqual({ username: "demo", password: "demo-pw" });
+    expect(service.env.copilotEnabled).toBe(true);
+    expect(service.env.inviteOnly).toBe(true);
+  });
+
+  // ─── load() orchestrator ──────────────────────────────────────────────────
+
+  it("load() only hits /config/pre-login when no access token is in localStorage", async () => {
+    const pending = firstValueFrom(service.load());
+    http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD);
+    // /config/gui and /config/user-system must not be requested when anonymous;
+    // the no-Authorization-header request would 403 and pollute network logs.
+    http.expectNone(`${API}/config/gui`);
+    http.expectNone(`${API}/config/user-system`);
+    await pending;
+    expect(service.env.localLogin).toBe(true);
+  });
+
+  it("load() chains /config/gui + /config/user-system when a token is stored", async () => {
+    localStorage.setItem(TOKEN_KEY, "stored-token");
+    const pending = firstValueFrom(service.load());
+
+    http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD);
+    // forkJoin fires both requests in parallel after pre-login resolves.
+    http.expectOne(`${API}/config/gui`).flush(GUI_PAYLOAD);
+    http.expectOne(`${API}/config/user-system`).flush(USER_SYSTEM_PAYLOAD);
+
+    await pending;
+    expect(service.env.localLogin).toBe(true);
+    expect(service.env.copilotEnabled).toBe(true);
+    expect(service.env.inviteOnly).toBe(true);
+  });
+
+  it("load() swallows post-login 403s so bootstrap is not blocked by an expired token", async () => {
+    // Stale token in localStorage would otherwise leave the app stuck on a
+    // blank screen — this is the exact failure mode that caused PR #5025 to
+    // revert the earlier eager-401 lockdown.
+    localStorage.setItem(TOKEN_KEY, "expired-token");
+    const pending = firstValueFrom(service.load());
+
+    http.expectOne(`${API}/config/pre-login`).flush(PRE_LOGIN_PAYLOAD);
+    const guiReq = http.expectOne(`${API}/config/gui`);
+    const userSystemReq = http.expectOne(`${API}/config/user-system`);
+    guiReq.flush({}, { status: 403, statusText: "Forbidden" });
+    // forkJoin tears down the sibling observable on first error, so the
+    // user-system request is already cancelled by the time we get here.
+    // Flushing a cancelled TestRequest throws.
+    if (!userSystemReq.cancelled) {
+      userSystemReq.flush({}, { status: 403, statusText: "Forbidden" });
+    }
+
+    await pending;
+    // Pre-login fields stay, post-login fields stay unset; the app degrades
+    // rather than failing to start.
+    expect(service.env.localLogin).toBe(true);
+    expect(service.env.copilotEnabled).toBeUndefined();
+    expect(service.env.inviteOnly).toBeUndefined();
+  });
+
+  it("load() rejects when /config/pre-login itself fails so the bootstrap error is surfaced", async () => {
+    localStorage.setItem(TOKEN_KEY, "stored-token");
+    const pending = firstValueFrom(service.load());
+    http.expectOne(`${API}/config/pre-login`).error(new ProgressEvent("offline"));
+    // /config/gui must NOT be attempted if pre-login fails — the catchError on
+    // the inner pipe must not catch the outer pre-login rejection.
+    http.expectNone(`${API}/config/gui`);
+    http.expectNone(`${API}/config/user-system`);
+    await expect(pending).rejects.toThrow(/pre-login configuration/);
+  });
+});
diff --git a/frontend/src/app/common/service/gui-config.service.ts b/frontend/src/app/common/service/gui-config.service.ts
index a2e164efeb..e96d98b191 100644
--- a/frontend/src/app/common/service/gui-config.service.ts
+++ b/frontend/src/app/common/service/gui-config.service.ts
@@ -20,45 +20,83 @@
 import { HttpClient } from "@angular/common/http";
 import { Injectable } from "@angular/core";
 import { GuiConfig } from "../type/gui-config";
-import { catchError, forkJoin, map, Observable, tap, throwError } from "rxjs";
+import { catchError, forkJoin, map, Observable, of, switchMap, tap, throwError } from "rxjs";
 import { AppSettings } from "../app-setting";
 
+// AuthService also owns this key but injects GuiConfigService, so importing it
+// back here would create a dependency cycle. Duplicating the constant is the
+// least invasive fix.
+const ACCESS_TOKEN_KEY = "access_token";
+
+type PreLoginConfig = Pick<GuiConfig, "localLogin" | "googleLogin" | "defaultLocalUser" | "attributionEnabled">;
+type GuiOnlyConfig = Omit<GuiConfig, keyof PreLoginConfig | "inviteOnly">;
+type UserSystemConfig = Pick<GuiConfig, "inviteOnly">;
+
 @Injectable({ providedIn: "root" })
 export class GuiConfigService {
-  private config!: GuiConfig;
+  private config: Partial<GuiConfig> = {};
 
   constructor(private http: HttpClient) {}
 
-  load(): Observable<GuiConfig> {
-    // Fetch both GUI config and user system config in parallel
-    const guiConfig$ = this.http.get<Omit<GuiConfig, "inviteOnly">>(`${AppSettings.getApiEndpoint()}/config/gui`);
-    const userSystemConfig$ = this.http.get<{ inviteOnly: boolean }>(
-      `${AppSettings.getApiEndpoint()}/config/user-system`
+  /**
+   * APP_INITIALIZER entry point. Always loads /config/pre-login (anonymous). If
+   * a JWT is already in localStorage (browser reload while logged in), chains
+   * /config/gui + /config/user-system in the same await so the full config is
+   * available before any post-login component mounts.
+   */
+  load(): Observable<Partial<GuiConfig>> {
+    return this.loadPreLogin().pipe(
+      switchMap(() => {
+        if (!GuiConfigService.hasStoredAccessToken()) {
+          return of(this.config);
+        }
+        return this.loadPostLogin().pipe(
+          // Expired or malformed token → /config/gui returns 403. Continue
+          // with pre-login only; UserService.loginWithExistingToken detects
+          // expiry on its own, so we shouldn't block bootstrap on it.
+          catchError((err: unknown) => {
+            console.warn("Failed to load authenticated config; continuing with pre-login only.", err);
+            return of(this.config);
+          })
+        );
+      })
     );
+  }
 
-    return forkJoin([guiConfig$, userSystemConfig$]).pipe(
-      map(([guiConfig, userSystemConfig]) => {
-        // Merge both configurations
-        return {
-          ...guiConfig,
-          ...userSystemConfig,
-        } as GuiConfig;
-      }),
-      tap(config => {
-        this.config = config;
-        console.log("GUI configuration loaded successfully from backend");
+  loadPreLogin(): Observable<Partial<GuiConfig>> {
+    return this.http.get<PreLoginConfig>(`${AppSettings.getApiEndpoint()}/config/pre-login`).pipe(
+      tap(preLogin => {
+        this.config = { ...this.config, ...preLogin };
       }),
+      map(() => this.config),
       catchError((error: unknown) => {
-        console.error("Failed to load GUI configuration:", error);
-        return throwError(() => new Error(`Failed to load GUI configuration from backend: ${error}`));
+        console.error("Failed to load pre-login configuration:", error);
+        return throwError(() => new Error(`Failed to load pre-login configuration from backend: ${error}`));
       })
     );
   }
 
+  /**
+   * Fetches the authenticated portion of the configuration. Runs after the
+   * frontend has a valid JWT — called from APP_INITIALIZER on bootstrap when a
+   * stored token exists, and from UserService.handleAccessToken on fresh login.
+   */
+  loadPostLogin(): Observable<Partial<GuiConfig>> {
+    const guiConfig$ = this.http.get<GuiOnlyConfig>(`${AppSettings.getApiEndpoint()}/config/gui`);
+    const userSystemConfig$ = this.http.get<UserSystemConfig>(`${AppSettings.getApiEndpoint()}/config/user-system`);
+    return forkJoin([guiConfig$, userSystemConfig$]).pipe(
+      tap(([guiConfig, userSystemConfig]) => {
+        this.config = { ...this.config, ...guiConfig, ...userSystemConfig };
+      }),
+      map(() => this.config)
+    );
+  }
+
   get env(): GuiConfig {
-    if (!this.config) {
-      throw new Error("GUI configuration not loaded yet. Make sure load() is called during app initialization");
-    }
-    return this.config;
+    return this.config as GuiConfig;
+  }
+
+  private static hasStoredAccessToken(): boolean {
+    return typeof localStorage !== "undefined" && localStorage.getItem(ACCESS_TOKEN_KEY) != null;
   }
 }
diff --git a/frontend/src/app/common/service/user/user.service.spec.ts b/frontend/src/app/common/service/user/user.service.spec.ts
index 8c99f7f279..199a9ff44b 100644
--- a/frontend/src/app/common/service/user/user.service.spec.ts
+++ b/frontend/src/app/common/service/user/user.service.spec.ts
@@ -24,11 +24,14 @@ import { UserService } from "./user.service";
 import { AuthService } from "./auth.service";
 import { StubAuthService } from "./stub-auth.service";
 import { skip } from "rxjs/operators";
+import { firstValueFrom, Subject, throwError } from "rxjs";
 import { commonTestProviders } from "../../testing/test-utils";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { GuiConfigService } from "../gui-config.service";
 
 describe("UserService", () => {
   let service: UserService;
+  let config: GuiConfigService;
 
   beforeEach(() => {
     AuthService.removeAccessToken();
@@ -38,6 +41,7 @@ describe("UserService", () => {
     });
 
     service = TestBed.inject(UserService);
+    config = TestBed.inject(GuiConfigService);
   });
 
   afterAll(() => {
@@ -108,4 +112,62 @@ describe("UserService", () => {
       expect((service as any).currentUser).toBeFalsy();
     });
   }));
+
+  // ─── post-login config fetch coordination ─────────────────────────────────
+
+  it("loads the authenticated config when a fresh login succeeds", async () => {
+    // /config/gui and /config/user-system are @RolesAllowed; their values must
+    // be in memory before any post-login component reads config.env, otherwise
+    // the dashboard renders against undefined flags.
+    const spy = vi.spyOn(config, "loadPostLogin");
+    await firstValueFrom(service.login("test", "password"));
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(service.isLogin()).toBe(true);
+  });
+
+  it("loads the authenticated config when a googleLogin succeeds", async () => {
+    // googleLogin shares the same handleAccessToken plumbing as username/password
+    // login, so the post-login config fetch must fire here too — otherwise a
+    // user who only ever signs in through Google would see undefined flags.
+    const spy = vi.spyOn(config, "loadPostLogin");
+    await firstValueFrom(service.googleLogin("any-credential"));
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(service.isLogin()).toBe(true);
+  });
+
+  it("orders the post-login config fetch before the userChanged event fires", async () => {
+    // Subscribers to userChanged (header, sidebar, routing guards) drive the
+    // initial dashboard render. If userChanged fires before loadPostLogin
+    // resolves, those subscribers see env without the authenticated fields and
+    // mis-render (e.g. copilot button missing, inviteOnly check skipped).
+    const gate = new Subject<unknown>();
+    vi.spyOn(config, "loadPostLogin").mockReturnValue(gate.asObservable() as any);
+
+    const userEmissions: Array<unknown> = [];
+    service
+      .userChanged()
+      .pipe(skip(1))
+      .subscribe(u => userEmissions.push(u));
+
+    const loginPromise = firstValueFrom(service.login("test", "password"));
+    // Login is in-flight; loadPostLogin has not resolved yet, so userChanged
+    // must NOT have emitted a logged-in user yet.
+    expect(userEmissions).toEqual([]);
+
+    gate.next({});
+    gate.complete();
+    await loginPromise;
+
+    expect(userEmissions.length).toBe(1);
+    expect(userEmissions[0]).toBeTruthy();
+  });
+
+  it("still completes login when loadPostLogin fails", async () => {
+    // Backend hiccup on /config/gui must not strand the user on a blank screen.
+    // The JwtAuthFilter on every protected endpoint is the authoritative gate;
+    // degraded config is preferable to a stuck spinner.
+    vi.spyOn(config, "loadPostLogin").mockReturnValue(throwError(() => new Error("simulated 500")));
+    await firstValueFrom(service.login("test", "password"));
+    expect(service.isLogin()).toBe(true);
+  });
 });
diff --git a/frontend/src/app/common/service/user/user.service.ts b/frontend/src/app/common/service/user/user.service.ts
index c5f2d43635..9a55df6487 100644
--- a/frontend/src/app/common/service/user/user.service.ts
+++ b/frontend/src/app/common/service/user/user.service.ts
@@ -24,7 +24,7 @@ import { Observable, of, ReplaySubject } from "rxjs";
 import { Role, User } from "../../type/user";
 import { AuthService } from "./auth.service";
 import { GuiConfigService } from "../gui-config.service";
-import { catchError, map, shareReplay } from "rxjs/operators";
+import { catchError, map, shareReplay, switchMap } from "rxjs/operators";
 
 /**
  * User Service manages User information. It relies on different
@@ -55,11 +55,13 @@ export class UserService {
     // validate the credentials with backend
     return this.authService
       .auth(username, password)
-      .pipe(map(({ accessToken }) => this.handleAccessToken(accessToken)));
+      .pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken)));
   }
 
   public googleLogin(credential: string): Observable<void> {
-    return this.authService.googleAuth(credential).pipe(map(({ accessToken }) => this.handleAccessToken(accessToken)));
+    return this.authService
+      .googleAuth(credential)
+      .pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken)));
   }
 
   public isLogin(): boolean {
@@ -82,7 +84,7 @@ export class UserService {
   public register(username: string, password: string): Observable<void> {
     return this.authService
       .register(username, password)
-      .pipe(map(({ accessToken }) => this.handleAccessToken(accessToken)));
+      .pipe(switchMap(({ accessToken }) => this.handleAccessToken(accessToken)));
   }
 
   /**
@@ -101,9 +103,24 @@ export class UserService {
     this.userChangeSubject.next(this.currentUser);
   }
 
-  private handleAccessToken(accessToken: string): void {
+  // Returns Observable<void> rather than void so callers (login / googleLogin /
+  // register) can switchMap through the post-login config fetch. The /config/gui
+  // and /config/user-system endpoints are @RolesAllowed, so we must wait for the
+  // new JWT to be in localStorage before they will answer; loginWithExistingToken
+  // also reads config.env.inviteOnly, so it must run after loadPostLogin resolves.
+  private handleAccessToken(accessToken: string): Observable<void> {
     AuthService.setAccessToken(accessToken);
-    this.changeUser(this.authService.loginWithExistingToken());
+    return this.config.loadPostLogin().pipe(
+      catchError((err: unknown) => {
+        // If the authenticated config fetch fails, still complete login with
+        // whatever we have. The JwtAuthFilter on every protected endpoint is
+        // the authoritative gate; degraded config is preferable to a stuck
+        // login flow.
+        console.warn("Failed to load authenticated config after login; continuing.", err);
+        return of(undefined);
+      }),
+      map(() => this.changeUser(this.authService.loginWithExistingToken()))
+    );
   }
 
   /**