Update Server Auth FIx

This commit is contained in:
Rokojori 2026-07-16 18:44:10 +02:00
parent 403499341f
commit 1e8ec50f6e
7 changed files with 114 additions and 25 deletions

View File

@ -1,8 +1,10 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com'; const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com';
const JWT_SECRET = process.env.JWT_SECRET ?? ''; 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 { export interface JwtUser {
userId: string; userId: string;
@ -32,6 +34,38 @@ function isApiRequest( req: Request ): boolean {
return req.path.startsWith( '/api/' ); 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<RefreshResult | null> {
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<RefreshResult>;
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 { export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void {
const token = extractToken( req ); 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; req.user = jwt.verify( token, JWT_SECRET ) as JwtUser;
next(); next();
} catch ( err: unknown ) { } 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 ); const redirect = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl );
res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` ); res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` );
} else { return;
next();
} }
// 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() );
} }
} }

View File

@ -47,7 +47,6 @@ task-title
font-weight: 600; font-weight: 600;
color: var( --item-color, var( --text ) ); color: var( --item-color, var( --text ) );
cursor: pointer; cursor: pointer;
user-select: none;
line-height: 1.5; line-height: 1.5;
} }

View File

@ -248,7 +248,6 @@ task-title
font-weight: 600; font-weight: 600;
color: var( --item-color, var( --text ) ); color: var( --item-color, var( --text ) );
cursor: pointer; cursor: pointer;
user-select: none;
line-height: 1.5; line-height: 1.5;
} }

View File

@ -135,22 +135,6 @@
</task-content> </task-content>
</task-item> </task-item>
<task-item class="blue hide-content">
<task-title>Investigate session logout after ~1 hour</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="blue hide-content"> <task-item class="blue hide-content">
<task-title>Switch Gitea webhook to dev branch</task-title> <task-title>Switch Gitea webhook to dev branch</task-title>
@ -205,6 +189,22 @@
<div class="lane"> <div class="lane">
<div class="lane-header">Done</div> <div class="lane-header">Done</div>
<task-item class="green hide-content">
<task-title>Fix session logout after ~1 hour — transparent token refresh</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="green hide-content"> <task-item class="green hide-content">
<task-title>CI deploy email notification</task-title> <task-title>CI deploy email notification</task-title>
<task-content> <task-content>

View File

@ -156,6 +156,48 @@
</section> </section>
<section>
<h2>Session 3 — Fix session logout after ~1 hour</h2>
<div class="card">
<h3>Root cause</h3>
<p>
<code>jwtMiddleware</code> in <code>source/server/middleware/auth.ts</code>
handled expired tokens differently for page requests vs API requests.
Page navigations were redirected to
<code>account.rokojori.com/api/auth/refresh-session</code> (correct).
API requests with an expired token fell into <code>else { next(); }</code>
with <code>req.user = undefined</code> — so <code>requireAuth</code>
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.
</p>
</div>
<div class="card">
<h3>Fix — transparent server-side refresh</h3>
<p>
When <code>TokenExpiredError</code> is caught on an API route and a
<code>refreshToken</code> cookie is present, <code>jwtMiddleware</code> now:
</p>
<ol style="line-height:1.9;margin-top:0.75rem">
<li>Calls <code>POST account.rokojori.com/api/auth/refresh</code> server-side
with the user's <code>refreshToken</code> cookie value.</li>
<li>Sets new <code>accessToken</code> and <code>refreshToken</code> cookies
on the response (same domain/options as rokojori-auth).</li>
<li>Decodes the new access token into <code>req.user</code> and calls
<code>next()</code> — the original API handler proceeds normally.</li>
</ol>
<p style="margin-top:0.75rem">
If the refresh fails (missing or expired refresh token, network error)
the request falls through to <code>requireAuth</code> which returns 401
as before — no silent swallowing. No frontend changes required.
</p>
</div>
</section>
<section> <section>
<h2>Key decisions</h2> <h2>Key decisions</h2>

View File

@ -21,7 +21,7 @@
<div class="card"> <div class="card">
<h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3> <h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3>
<p>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.</p> <p>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.</p>
</div> </div>
<div class="card"> <div class="card">

View File

@ -13,7 +13,7 @@
<header> <header>
<p class="date">Project Documentation</p> <p class="date">Project Documentation</p>
<h1 style="font-size: 300%;">Roject</h1> <h1 style="font-size: 300%;">Roject</h1>
<p class="subtitle">For editing files in projects</p> <p class="subtitle">Online and local digital projects editor</p>
</header> </header>
<section> <section>