Add auto-logout on session expiry for helix-front#186
Conversation
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; |
There was a problem hiding this comment.
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.
| ): Observable<HttpEvent<any>> { | ||
| return next.handle(req).pipe( | ||
| catchError((error: HttpErrorResponse) => { | ||
| if (error.status === 401 && !this.sessionExpiredShown) { |
There was a problem hiding this comment.
Catches every 401, including /user/login. Login returns 401 on bad credentials (user.ts:86, :99). So:
- User types wrong password
- Server 401s
- Interceptor pops "Session Expired" dialog (wrong message, they weren't signed in)
- Line 44 returns
EMPTYso the login subscribe's error handler atapp.component.ts:134never fires and they don't see the actual "wrong password" error - 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);
}|
|
||
| @Injectable() | ||
| export class AuthInterceptor implements HttpInterceptor { | ||
| private sessionExpiredShown = false; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.destroyerror (returns 500 instead of silentlyres.json(true)) - clears
connect.sidexplicitly along withhelixui_identity.token
When you rebase against #184, drop this one and keep that.
There was a problem hiding this comment.
I will upgrade the logout() here so that we are not much concerned with order of merge.
| } | ||
|
|
||
| private hasIdentityToken(): boolean { | ||
| return document.cookie.includes('helixui_identity.token'); |
There was a problem hiding this comment.
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='));…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>
|
|
||
| private watchTokenExpiry() { | ||
| if (!this.hasIdentityToken()) return; | ||
| const check = setInterval(() => { |
There was a problem hiding this comment.
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);
}…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>
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.

This PR adds both proactive and reactive session expiry handling:
helixui_identity.tokencookie every 30 seconds. When the cookie disappears (token expired), immediately shows a "Session Expired" dialog - even if the user is idle./api/user/logoutto destroy the server session + clear cookies, then reloads the page so the UI reflects logged-out state/api/user/logoutserver routeBefore vs After
Testing Done
Screen.Recording.2026-05-19.at.2.25.52.PM.mov
Screen.Recording.2026-05-19.at.2.34.06.PM.mov
gkabrawith identity token cookie set to expire in 1 minuteFiles changed
src/app/core/auth.interceptor.tssrc/app/app.module.tsHTTP_INTERCEPTORSsrc/app/app.component.tsserver/controllers/helix.tsserver/controllers/user.ts/api/user/logoutendpoint to clear session + cookies