Skip to content

Add auto-logout on session expiry for helix-front#186

Merged
kabragaurav merged 5 commits into
devfrom
gkabra/auto-logout-on-session-expiry-v2
May 27, 2026
Merged

Add auto-logout on session expiry for helix-front#186
kabragaurav merged 5 commits into
devfrom
gkabra/auto-logout-on-session-expiry-v2

Conversation

@kabragaurav
Copy link
Copy Markdown
Collaborator

Summary

When a user's identity token expires, helix-front currently shows cryptic error messages (e.g., "403 OK") with no indication that re-login is needed. The user appears logged in (username still shown) but all write operations fail silently. This was reported in #incident-11807 [SEV-2] and in-person chat.
Screenshot 2026-05-19 at 2 09 23 PM

This PR adds both proactive and reactive session expiry handling:

  • Proactive detection: Polls for the helixui_identity.token cookie every 30 seconds. When the cookie disappears (token expired), immediately shows a "Session Expired" dialog - even if the user is idle.
  • Reactive detection: HTTP interceptor catches 401 responses on all API calls and shows the same dialog
  • Server-side guard: Returns 401 when identity token cookie is missing on write requests (instead of forwarding a stale/empty token to the Helix REST API)
Screenshot 2026-05-19 at 12 57 45 PM
  • Clean logout: On dialog close, calls /api/user/logout to destroy the server session + clear cookies, then reloads the page so the UI reflects logged-out state
  • Logout endpoint: Added /api/user/logout server route

Before vs After

Before After
User sees cryptic "403 OK" error User sees clear "Session Expired" dialog
User still appears logged in User is logged out and shown "Sign In"
No guidance on what to do Dialog prompts re-login, auto-logs out on close

Testing Done

Screen.Recording.2026-05-19.at.2.25.52.PM.mov
Screen.Recording.2026-05-19.at.2.34.06.PM.mov
  1. Started helix-front locally with mock mode (mock Helix REST + mock LDAP login)
  2. Logged in as gkabra with identity token cookie set to expire in 1 minute
  3. Navigated to TestCluster > Configuration tab
  4. Proactive test: Waited ~1 minute without any action. "Session Expired" dialog appeared automatically after cookie expired
  5. Reactive test: Tried editing a config field after token expired. "Session Expired" dialog appeared
  6. Closed dialog -> logged out -> page reloaded -> showed "Sign In" button (fully logged out)

Files changed

File Change
src/app/core/auth.interceptor.ts New - HTTP interceptor: catches 401, shows "Session Expired" dialog, calls logout on close
src/app/app.module.ts Register interceptor via HTTP_INTERCEPTORS
src/app/app.component.ts Proactive token cookie watch (30s poll interval)
server/controllers/helix.ts Return 401 when identity token missing on write requests
server/controllers/user.ts Add /api/user/logout endpoint to clear session + cookies

Gaurav Kabra and others added 3 commits May 26, 2026 19:29
When a user's identity token expires, API calls fail with cryptic
errors and no indication that re-login is needed. This adds:

- HTTP interceptor that catches 401 responses on all API calls and
  shows a "Session expired" snackbar with a Sign In action button
- Server-side check that returns 401 when identity token cookie is
  missing on mutating requests (instead of forwarding a stale token)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Interceptor now shows a modal dialog instead of snackbar on 401,
  and swallows the error to prevent duplicate error popups
- Proactive token watch: polls for identity token cookie every 30s,
  shows session expired dialog immediately when cookie disappears
- On dialog close: calls /api/user/logout to destroy server session,
  then reloads page so UI reflects logged-out state
- Added /api/user/logout endpoint to clear session and token cookie
- Reordered server checks: token check before isAdmin check so
  expired tokens get 401 (not 403)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e idiomatic RxJS

- Use session.destroy() instead of session=null for proper cleanup
- Add sessionExpiredShown guard in interceptor to prevent multiple dialogs
- Check dialog.openDialogs.length in proactive watcher to avoid conflict
- Replace new Observable(subscriber.error) with throwError (idiomatic RxJS)
- Add destroy() to HelixSession interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
}

private watchTokenExpiry() {
if (!this.hasIdentityToken()) return;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kills the primary feature. ngOnInit runs once at boot. If the user opens Helix logged-out, hasIdentityToken() is false, this returns, and the interval is never set. They then click Sign In, work for
24h, token expires, and no dialog ever fires because no poll is running. login() at line 101 doesn't re-arm the watcher.

Your test plan masks this by setting a cookie before page load. Real flow (open -> Sign In -> wait) gives you only the reactive interceptor.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed.

): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401 && !this.sessionExpiredShown) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catches every 401, including /user/login. Login returns 401 on bad credentials (user.ts:86, :99). So:

  1. User types wrong password
  2. Server 401s
  3. Interceptor pops "Session Expired" dialog (wrong message, they weren't signed in)
  4. Line 44 returns EMPTY so the login subscribe's error handler at app.component.ts:134 never fires and they don't see the actual "wrong password" error
  5. They click OK, get logged out, reload, start over confused

Skip the interceptor for the user endpoints at the top of intercept():

if (req.url.includes('/api/user/login') || req.url.includes('/api/user/logout')) {
  return next.handle(req);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private sessionExpiredShown = false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two sessionExpiredShown flags, here and at app.component.ts:30. No shared state. The poll checks dialog.openDialogs.length === 0 (line 79) as a soft guard, this doesn't.

Race: poll fires dialog at T+0, in-flight request 401s at T+50ms, interceptor pops a second dialog on top, both afterClosed() handlers run -> double fetch /logout + double reload.

Also the flag never resets. If reload doesn't happen for any reason, every subsequent 401 is silently swallowed by return EMPTY at line 44.

Pull this into a SessionExpiryService that owns the flag, the dialog, and the logout call. Both the poll and the interceptor delegate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe a full SessionExpiryService will be an over-engineering for this PR. I will rather prefer doing a simple this.dialog.openDialogs.length === 0.

// uncomment the following line to use customized login
// router.route('/user/authorize').get(this.authorize);
router.route('/user/login').post(this.login.bind(this));
router.route('/user/logout').post(this.logout);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflicts with #184 which also adds /user/logout and a logout() method. Rebase order decides which wins.

Fwiw #184's version is better:

  • handles session.destroy error (returns 500 instead of silently res.json(true))
  • clears connect.sid explicitly along with helixui_identity.token

When you rebase against #184, drop this one and keep that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will upgrade the logout() here so that we are not much concerned with order of merge.

Comment thread helix-front/src/app/app.component.ts Outdated
}

private hasIdentityToken(): boolean {
return document.cookie.includes('helixui_identity.token');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substring match. Would also true-positive on helixui_identity.tokenized, helixui_identity.token_v2, etc. We don't have any today but it's a sharp edge.

return document.cookie.split(';').some(c => c.trim().startsWith('helixui_identity.token='));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

…or for login/logout, use dialog guard for race, improve logout error handling, fix cookie substring match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread helix-front/src/app/app.component.ts Outdated

private watchTokenExpiry() {
if (!this.hasIdentityToken()) return;
const check = setInterval(() => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that watchTokenExpiry() is called from both ngOnInit and the login success handler, repeated login/logout cycles (via #184's manual Sign Out, which doesn't reload) will stack intervals. Each new
login creates a fresh setInterval without clearing the previous. Not a correctness bug since they all share the dialog.openDialogs.length check, but it's an actual leak.

Track the handle and clear before re-arming:

private expiryCheckHandle?: ReturnType<typeof setInterval>;

private watchTokenExpiry() {
  if (this.expiryCheckHandle) clearInterval(this.expiryCheckHandle);
  if (!this.hasIdentityToken()) return;
  this.expiryCheckHandle = setInterval(() => { ... }, 30000);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed

…d login cycles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@LZD-PratyushBhatt LZD-PratyushBhatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kabragaurav kabragaurav merged commit afd77a2 into dev May 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants