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

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


The following commit(s) were added to refs/heads/main by this push:
     new 749798d821 fix(frontend): handle 401 by clearing session, skip sending 
expired JWTs (#5392)
749798d821 is described below

commit 749798d8214a5999b2870ebcf3f62fefd609edfa
Author: Yicong Huang <[email protected]>
AuthorDate: Sat Jun 6 00:15:12 2026 -0700

    fix(frontend): handle 401 by clearing session, skip sending expired JWTs 
(#5392)
    
    ### What changes were proposed in this PR?
    
    Adds `UnauthorizedHttpInterceptor` that watches every HTTP response:
    when the server returns 401 to a request that carried an `Authorization`
    header, it routes the logout through `UserService.logout()` (which
    clears the JWT and broadcasts `userChanged(undefined)` so header /
    dashboard / in-memory state stay in sync), surfaces a "session expired"
    notification, and routes to `/about` with `returnUrl`. 401 responses to
    anonymous or auth-endpoint requests pass through unchanged so a 401 on a
    public endpoint never wipes a freshly-stored token, and a wrong-password
    401 on `/auth/login` cannot kick an already-authenticated user out.
    
    Two adversarial-review findings (caught during self-review, fixed in
    commit 2):
    
    1. **Auth-endpoint allowlist.** `JwtModule` blanket-attaches
    `Authorization` to every request, including `POST /api/auth/login`. A
    wrong-password 401 there carries the user's existing token and would be
    misread as "session invalid", kicking an already-authenticated user out
    the moment they fat-finger a re-login. Allowlist regex
    `\/auth\/(login|register|refresh|google\/login)` short-circuits the
    interceptor for these URLs.
    
    2. **Concurrent 401 dedup.** A burst of 8-10 parallel dashboard
    requests, all 401'd by a server-side revoke, would fire 8 toasts and 8
    `router.navigate` calls. Routing the logout through
    `UserService.logout()` (which flips `isLogin()` from true to false)
    gives natural dedup: only the first 401 sees `isLogin() === true` and
    fires side effects; the rest of the burst short-circuits. The flag
    re-arms automatically after a successful re-login.
    
    Flips `JwtModule.forRoot`'s `skipWhenExpired` from `false` to `true`, so
    a stale `localStorage` token is no longer auto-appended to public
    pre-login endpoints (`/api/config/gui`, `/api/config/user-system`). That
    auto-append is the exact mechanism that caused the revert of #4903 in
    #5025 — without it, an expired token reaches public endpoints, gets
    rejected, and breaks the login page before the user can re-authenticate.
    
    `UserService` is resolved lazily via `Injector` inside `catchError`
    because it transitively depends on `HttpClient`, and direct injection
    would form a DI cycle through the interceptor chain.
    
    ### Any related issues, documentation, discussions?
    
    Closes #5391. Prerequisite for a re-attempt of #4901 (whose first try,
    #4903, was reverted by #5025 because of #5026). Discussion:
    https://lists.apache.org/thread/w5jmczrffxd5doc5hnss2zm7dbhnbhyy
    
    ### How was this PR tested?
    
    Added `unauthorized-http-interceptor.service.spec.ts` with 12 cases
    covering: 401 with Authorization → logout + notify + navigate; 401
    without Authorization → no-op; non-401 with Authorization → no-op; root
    path → omits `returnUrl`; auth-endpoint URLs (login / register / refresh
    / google/login, with and without query strings) → no-op; over-broad URL
    match guard (`recent-logins` still logs out); concurrent 401 burst →
    exactly one side-effect set; re-arms after re-login.
    Tested manually as well.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.7)
---
 frontend/src/app/app.module.ts                     |   8 +-
 .../unauthorized-http-interceptor.service.spec.ts  | 193 +++++++++++++++++++++
 .../unauthorized-http-interceptor.service.ts       |  87 ++++++++++
 3 files changed, 287 insertions(+), 1 deletion(-)

diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 78fc75d7cb..485b3c2e4a 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -78,6 +78,7 @@ import { NzCardModule } from "ng-zorro-antd/card";
 import { NzTagModule } from "ng-zorro-antd/tag";
 import { NzAvatarModule } from "ng-zorro-antd/avatar";
 import { BlobErrorHttpInterceptor } from 
"./common/service/blob-error-http-interceptor.service";
+import { UnauthorizedHttpInterceptor } from 
"./common/service/unauthorized-http-interceptor.service";
 import { ConsoleFrameComponent } from 
"./workspace/component/result-panel/console-frame/console-frame.component";
 import { ResultTableFrameComponent } from 
"./workspace/component/result-panel/result-table-frame/result-table-frame.component";
 import { RowModalComponent } from 
"./workspace/component/result-panel/result-panel-modal.component";
@@ -203,7 +204,7 @@ registerLocaleData(en);
     JwtModule.forRoot({
       config: {
         tokenGetter: AuthService.getAccessToken,
-        skipWhenExpired: false,
+        skipWhenExpired: true,
         throwNoTokenError: false,
         disallowedRoutes: ["forum/api/users"],
       },
@@ -375,6 +376,11 @@ registerLocaleData(en);
       useClass: BlobErrorHttpInterceptor,
       multi: true,
     },
+    {
+      provide: HTTP_INTERCEPTORS,
+      useClass: UnauthorizedHttpInterceptor,
+      multi: true,
+    },
     {
       provide: "SocialAuthServiceConfig",
       useFactory: (googleAuthService: GoogleAuthService, userService: 
UserService) => {
diff --git 
a/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts 
b/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts
new file mode 100644
index 0000000000..1b11d9128b
--- /dev/null
+++ 
b/frontend/src/app/common/service/unauthorized-http-interceptor.service.spec.ts
@@ -0,0 +1,193 @@
+/**
+ * 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 { HTTP_INTERCEPTORS, HttpClient } from "@angular/common/http";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { TestBed } from "@angular/core/testing";
+import { Router } from "@angular/router";
+import { ABOUT } from "../../app-routing.constant";
+import { NotificationService } from "./notification/notification.service";
+import { UserService } from "./user/user.service";
+import { UnauthorizedHttpInterceptor } from 
"./unauthorized-http-interceptor.service";
+
+describe("UnauthorizedHttpInterceptor", () => {
+  let http: HttpClient;
+  let httpMock: HttpTestingController;
+  let routerSpy: { navigate: ReturnType<typeof vi.fn>; url: string };
+  let notificationSpy: { error: ReturnType<typeof vi.fn> };
+  let userServiceSpy: { logout: ReturnType<typeof vi.fn>; isLogin: 
ReturnType<typeof vi.fn> };
+
+  beforeEach(() => {
+    routerSpy = { navigate: vi.fn(), url: "/user/workflow/42" };
+    notificationSpy = { error: vi.fn() };
+    userServiceSpy = { logout: vi.fn(), isLogin: vi.fn().mockReturnValue(true) 
};
+
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [
+        { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedHttpInterceptor, 
multi: true },
+        { provide: Router, useValue: routerSpy },
+        { provide: NotificationService, useValue: notificationSpy },
+        { provide: UserService, useValue: userServiceSpy },
+      ],
+    });
+
+    http = TestBed.inject(HttpClient);
+    httpMock = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+  });
+
+  function authedGet(url: string) {
+    return http.get(url, { headers: { Authorization: "Bearer stale-token" } });
+  }
+
+  it("logs out, notifies, and redirects to ABOUT on 401 for an authenticated 
request", () => {
+    // The decision to log out hinges on whether *this* request was 
authenticated.
+    // A 401 from an anonymous request is the server saying "you need to log 
in",
+    // not "your session is invalid" — clearing the session there would wipe a
+    // freshly-stored token (e.g. mid-login race).
+    authedGet("/api/secret").subscribe({ error: () => {} });
+    httpMock.expectOne("/api/secret").flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+    expect(userServiceSpy.logout).toHaveBeenCalledTimes(1);
+    expect(notificationSpy.error).toHaveBeenCalledTimes(1);
+    
expect(notificationSpy.error.mock.calls[0][0]).toMatch(/session.*expired|log 
in/i);
+    expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], {
+      queryParams: { returnUrl: "/user/workflow/42" },
+    });
+  });
+
+  it("leaves the session untouched when 401 comes back for an anonymous 
request", () => {
+    // Reproduces the #5026 / #4903-revert scenario: a public endpoint (or one
+    // whose token JwtModule skipped because it was expired) returning 401
+    // must NOT trigger a logout — the user may not even be logged in yet.
+    http.get("/api/public").subscribe({ error: () => {} });
+    httpMock.expectOne("/api/public").flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+    expect(userServiceSpy.logout).not.toHaveBeenCalled();
+    expect(notificationSpy.error).not.toHaveBeenCalled();
+    expect(routerSpy.navigate).not.toHaveBeenCalled();
+  });
+
+  it("does not log out on non-401 errors even when Authorization was sent", () 
=> {
+    authedGet("/api/oops").subscribe({ error: () => {} });
+    httpMock.expectOne("/api/oops").flush(null, { status: 500, statusText: 
"Server Error" });
+
+    expect(userServiceSpy.logout).not.toHaveBeenCalled();
+    expect(notificationSpy.error).not.toHaveBeenCalled();
+    expect(routerSpy.navigate).not.toHaveBeenCalled();
+  });
+
+  it("omits returnUrl when the current route is the root", () => {
+    routerSpy.url = "/";
+    authedGet("/api/secret").subscribe({ error: () => {} });
+    httpMock.expectOne("/api/secret").flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+    expect(routerSpy.navigate).toHaveBeenCalledWith([ABOUT], { queryParams: { 
returnUrl: null } });
+  });
+
+  // Adversarial-review fix #1: a stale token gets auto-attached to /auth/login
+  // by JwtModule. A wrong-password 401 from login must NOT be misread as
+  // "your session is invalid", or we'd kick an already-authenticated user
+  // out the moment they fat-finger a re-login.
+  describe("auth-endpoint allowlist", () => {
+    const authPaths = [
+      "/api/auth/login",
+      "/api/auth/register",
+      "/api/auth/refresh",
+      "/api/auth/google/login",
+      "/api/auth/login?next=/dashboard",
+    ];
+
+    authPaths.forEach(path => {
+      it(`does not log out on 401 from ${path}`, () => {
+        authedGet(path).subscribe({ error: () => {} });
+        httpMock.expectOne(path).flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+        expect(userServiceSpy.logout).not.toHaveBeenCalled();
+        expect(notificationSpy.error).not.toHaveBeenCalled();
+        expect(routerSpy.navigate).not.toHaveBeenCalled();
+      });
+    });
+
+    it("still logs out on 401 from a URL that merely contains 'login' in a 
non-auth segment", () => {
+      // Guards against an over-broad regex like /login/ that would skip e.g.
+      // /api/dashboard/recent-logins.
+      authedGet("/api/dashboard/recent-logins").subscribe({ error: () => {} });
+      httpMock.expectOne("/api/dashboard/recent-logins").flush(null, { status: 
401, statusText: "Unauthorized" });
+
+      expect(userServiceSpy.logout).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  // Adversarial-review fix #2: dashboard load fires 8-10 parallel requests; a
+  // server-side revoke makes them all 401 within microseconds of each other.
+  // Side effects must fire exactly once, not once per request — otherwise we
+  // get a toast pile-up and redundant navigations.
+  describe("concurrent 401 deduplication", () => {
+    it("fires logout side effects exactly once for a burst of 401s", () => {
+      // Simulate UserService transitioning from logged-in → logged-out as soon
+      // as logout() is called; this is what UserService.changeUser(undefined)
+      // does in production and is the discriminator the interceptor reads.
+      userServiceSpy.logout.mockImplementation(() => {
+        userServiceSpy.isLogin.mockReturnValue(false);
+      });
+
+      authedGet("/api/a").subscribe({ error: () => {} });
+      authedGet("/api/b").subscribe({ error: () => {} });
+      authedGet("/api/c").subscribe({ error: () => {} });
+
+      httpMock.expectOne("/api/a").flush(null, { status: 401, statusText: 
"Unauthorized" });
+      httpMock.expectOne("/api/b").flush(null, { status: 401, statusText: 
"Unauthorized" });
+      httpMock.expectOne("/api/c").flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+      expect(userServiceSpy.logout).toHaveBeenCalledTimes(1);
+      expect(notificationSpy.error).toHaveBeenCalledTimes(1);
+      expect(routerSpy.navigate).toHaveBeenCalledTimes(1);
+    });
+
+    it("re-arms after a successful re-login so a later 401 triggers logout 
again", () => {
+      // After logout, isLogin() is false. Dedup keys off isLogin(), so when 
the
+      // user re-logs in (isLogin() flips back to true) and a fresh request 
401s,
+      // the interceptor must fire side effects again. Otherwise the user gets
+      // stuck in a half-logged-in state after a transient session error.
+      userServiceSpy.logout.mockImplementationOnce(() => {
+        userServiceSpy.isLogin.mockReturnValue(false);
+      });
+
+      authedGet("/api/a").subscribe({ error: () => {} });
+      httpMock.expectOne("/api/a").flush(null, { status: 401, statusText: 
"Unauthorized" });
+      expect(userServiceSpy.logout).toHaveBeenCalledTimes(1);
+
+      // Simulate re-login: isLogin() back to true.
+      userServiceSpy.isLogin.mockReturnValue(true);
+      userServiceSpy.logout.mockImplementationOnce(() => {
+        userServiceSpy.isLogin.mockReturnValue(false);
+      });
+
+      authedGet("/api/b").subscribe({ error: () => {} });
+      httpMock.expectOne("/api/b").flush(null, { status: 401, statusText: 
"Unauthorized" });
+
+      expect(userServiceSpy.logout).toHaveBeenCalledTimes(2);
+    });
+  });
+});
diff --git 
a/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts 
b/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts
new file mode 100644
index 0000000000..2ff0cbc5db
--- /dev/null
+++ b/frontend/src/app/common/service/unauthorized-http-interceptor.service.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, 
HttpRequest } from "@angular/common/http";
+import { Injectable, Injector } from "@angular/core";
+import { Router } from "@angular/router";
+import { Observable, throwError } from "rxjs";
+import { catchError } from "rxjs/operators";
+import { ABOUT } from "../../app-routing.constant";
+import { NotificationService } from "./notification/notification.service";
+import { UserService } from "./user/user.service";
+
+// Endpoints whose own 401 means "wrong credentials", not "your session is 
invalid".
+// JwtModule blanket-attaches Authorization to every request, so a stale token
+// gets piggy-backed on login/register/refresh attempts even when the user is
+// trying to *acquire* a session. Treating those 401s as session-invalidation
+// would kick out an already-authenticated user who fat-fingers a re-login.
+const AUTH_ENDPOINT_PATTERN = 
/\/auth\/(login|register|refresh|google\/login)(?:\?|$)/;
+
+/**
+ * Globally handles 401 responses that come back for *authenticated* requests:
+ * routes the logout through UserService (which clears the token and broadcasts
+ * `userChanged(undefined)` so header / dashboard / in-memory state stay in
+ * sync), surfaces a notification, and routes to the landing page with
+ * returnUrl. 401s on anonymous, auth-endpoint, or already-logged-out paths
+ * pass through unchanged. See #5391 / #4901 / #5026.
+ *
+ * UserService is resolved lazily via Injector because it (transitively)
+ * depends on HttpClient, and HttpClient depends on the interceptor chain —
+ * direct injection here would form a DI cycle.
+ */
+@Injectable()
+export class UnauthorizedHttpInterceptor implements HttpInterceptor {
+  constructor(
+    private injector: Injector,
+    private router: Router,
+    private notificationService: NotificationService
+  ) {}
+
+  public intercept(req: HttpRequest<unknown>, next: HttpHandler): 
Observable<HttpEvent<unknown>> {
+    return next.handle(req).pipe(
+      catchError((err: unknown) => {
+        if (this.shouldHandleAsSessionExpired(req, err)) {
+          const userService = this.injector.get(UserService);
+          // Dedup: if a burst of 401s arrives, only the first one through here
+          // sees isLogin()=true. logout() flips it to false (via changeUser),
+          // so the rest of the burst skip the side effects. After a real
+          // re-login isLogin() flips back to true and the interceptor re-arms.
+          if (userService.isLogin()) {
+            userService.logout();
+            this.notificationService.error("Your session has expired. Please 
log in again.");
+            const currentUrl = this.router.url;
+            this.router.navigate([ABOUT], {
+              queryParams: { returnUrl: currentUrl === "/" ? null : currentUrl 
},
+            });
+          }
+        }
+        return throwError(() => err);
+      })
+    );
+  }
+
+  private shouldHandleAsSessionExpired(req: HttpRequest<unknown>, err: 
unknown): boolean {
+    return (
+      err instanceof HttpErrorResponse &&
+      err.status === 401 &&
+      req.headers.has("Authorization") &&
+      !AUTH_ENDPOINT_PATTERN.test(req.url)
+    );
+  }
+}

Reply via email to