diff --git a/source/server/middleware/auth.ts b/source/server/middleware/auth.ts index fd7a9c6..26f2698 100644 --- a/source/server/middleware/auth.ts +++ b/source/server/middleware/auth.ts @@ -1,8 +1,10 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; -const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com'; -const JWT_SECRET = process.env.JWT_SECRET ?? ''; +const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com'; +const AUTH_INTERNAL_HOST = process.env.AUTH_INTERNAL_HOST ?? AUTH_HOST; +const JWT_SECRET = process.env.JWT_SECRET ?? ''; +const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; export interface JwtUser { userId: string; @@ -32,6 +34,38 @@ function isApiRequest( req: Request ): boolean { return req.path.startsWith( '/api/' ); } +function cookieOpts( maxAge: number ) { + return { + domain: COOKIE_DOMAIN, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', + maxAge + }; +} + +interface RefreshResult { + accessToken: string; + refreshToken: string; +} + +async function tryRefresh( refreshToken: string ): Promise { + try { + const r = await fetch( `${AUTH_INTERNAL_HOST}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { refreshToken } ) + } ); + if ( !r.ok ) return null; + const data = await r.json() as Partial; + if ( !data.accessToken || !data.refreshToken ) return null; + return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + } catch { + return null; + } +} + export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void { const token = extractToken( req ); @@ -41,12 +75,27 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction ) req.user = jwt.verify( token, JWT_SECRET ) as JwtUser; next(); } catch ( err: unknown ) { - if ( err instanceof jwt.TokenExpiredError && !isApiRequest( req ) ) { + if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } + + if ( !isApiRequest( req ) ) { const redirect = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl ); res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` ); - } else { - next(); + return; } + + // API request with expired token — try transparent refresh via refreshToken cookie + const refreshToken = req.cookies?.refreshToken as string | undefined; + if ( !refreshToken ) { next(); return; } + + tryRefresh( refreshToken ).then( result => { + if ( !result ) { next(); return; } + res.cookie( 'accessToken', result.accessToken, cookieOpts( 60 * 60 * 1000 ) ); + res.cookie( 'refreshToken', result.refreshToken, cookieOpts( 30 * 24 * 60 * 60 * 1000 ) ); + try { + req.user = jwt.verify( result.accessToken, JWT_SECRET ) as JwtUser; + } catch { /* fall through — requireAuth will return 401 */ } + next(); + } ).catch( () => next() ); } } diff --git a/workspace/_assets_/boards.css b/workspace/_assets_/boards.css index 846590c..7033ef0 100644 --- a/workspace/_assets_/boards.css +++ b/workspace/_assets_/boards.css @@ -47,7 +47,6 @@ task-title font-weight: 600; color: var( --item-color, var( --text ) ); cursor: pointer; - user-select: none; line-height: 1.5; } diff --git a/workspace/_assets_/styles.css b/workspace/_assets_/styles.css index 88ff6a3..02ed64c 100644 --- a/workspace/_assets_/styles.css +++ b/workspace/_assets_/styles.css @@ -248,7 +248,6 @@ task-title font-weight: 600; color: var( --item-color, var( --text ) ); cursor: pointer; - user-select: none; line-height: 1.5; } diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html index da3abe8..e4c25b3 100644 --- a/workspace/boards/tasks.html +++ b/workspace/boards/tasks.html @@ -135,22 +135,6 @@ - - Investigate session logout after ~1 hour - - Users are logged out after a couple of hours. The access token issued by - rokojori-auth expires after 1 hour; the refresh token lasts 30 days. - Roject should silently refresh via GET account.rokojori.com/api/auth/refresh-session - before the token expires. - - Investigate: - — Is the 401 response from any API route triggering a redirect to refresh-session? - — Is the refreshToken cookie present and being sent cross-domain? - — Is the refresh-session endpoint actually rotating both cookies correctly? - — Check journalctl on the server for 401 patterns and the browser network tab - for which request first returns 401. - - Switch Gitea webhook to dev branch @@ -205,6 +189,22 @@
Done
+ + Fix session logout after ~1 hour — transparent token refresh + + Root cause: jwtMiddleware only redirected to refresh-session for page navigations. + API requests with an expired token fell through with req.user = undefined, causing + requireAuth to return 401 — no retry, no refresh, silent failure mid-session. + + Fix: when TokenExpiredError hits an API route and a refreshToken cookie is present, + jwtMiddleware now calls POST account.rokojori.com/api/auth/refresh server-side, + sets the new accessToken and refreshToken cookies on the response, decodes the new + JWT into req.user, and calls next(). Completely transparent — no frontend changes. + If refresh fails (expired or missing refresh token) the request falls through to + requireAuth which returns 401 as before. + + + CI deploy email notification diff --git a/workspace/history/2026/07-July/16-Wednesday/index.html b/workspace/history/2026/07-July/16-Wednesday/index.html index 4f22567..6511c3a 100644 --- a/workspace/history/2026/07-July/16-Wednesday/index.html +++ b/workspace/history/2026/07-July/16-Wednesday/index.html @@ -156,6 +156,48 @@ +
+

Session 3 — Fix session logout after ~1 hour

+ +
+

Root cause

+

+ jwtMiddleware in source/server/middleware/auth.ts + handled expired tokens differently for page requests vs API requests. + Page navigations were redirected to + account.rokojori.com/api/auth/refresh-session (correct). + API requests with an expired token fell into else { next(); } + with req.user = undefined — so requireAuth + returned 401 and the SPA had no way to recover. Because the editor never + navigates after load, the page-level redirect never fired mid-session, + causing every API call (save, file tree, settings) to silently fail after + the 1-hour access token expired. +

+
+ +
+

Fix — transparent server-side refresh

+

+ When TokenExpiredError is caught on an API route and a + refreshToken cookie is present, jwtMiddleware now: +

+
    +
  1. Calls POST account.rokojori.com/api/auth/refresh server-side + with the user's refreshToken cookie value.
  2. +
  3. Sets new accessToken and refreshToken cookies + on the response (same domain/options as rokojori-auth).
  4. +
  5. Decodes the new access token into req.user and calls + next() — the original API handler proceeds normally.
  6. +
+

+ If the refresh fails (missing or expired refresh token, network error) + the request falls through to requireAuth which returns 401 + as before — no silent swallowing. No frontend changes required. +

+
+ +
+

Key decisions

diff --git a/workspace/history/index.html b/workspace/history/index.html index 1e50792..2647ebf 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -21,7 +21,7 @@

Wednesday, 16 July 2026

-

rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent app, production deployment to tunnel.rokojori.com, streaming relay protocol (res_start/res_data/res_end), Roject browse-tunnels UI, tunnel-backed LLM chat, and client-side chunk animation for smooth streaming appearance.

+

rokojori-tunnel: Phase 1 relay server, Electron Tunnel Agent app, production deployment to tunnel.rokojori.com, streaming relay protocol (res_start/res_data/res_end), Roject browse-tunnels UI, tunnel-backed LLM chat, and client-side chunk animation for smooth streaming appearance. Session 3: fixed session logout after ~1 hour — transparent server-side token refresh in jwtMiddleware for API routes.

diff --git a/workspace/index.html b/workspace/index.html index cbd1587..bb13426 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -13,7 +13,7 @@

Project Documentation

Roject

-

For editing files in projects

+

Online and local digital projects editor