Update Server Auth FIx
This commit is contained in:
parent
403499341f
commit
1e8ec50f6e
|
|
@ -2,7 +2,9 @@ import { Request, Response, NextFunction } from 'express';
|
|||
import jwt from 'jsonwebtoken';
|
||||
|
||||
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<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 {
|
||||
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() );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ task-title
|
|||
font-weight: 600;
|
||||
color: var( --item-color, var( --text ) );
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,7 +248,6 @@ task-title
|
|||
font-weight: 600;
|
||||
color: var( --item-color, var( --text ) );
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,22 +135,6 @@
|
|||
</task-content>
|
||||
</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-title>Switch Gitea webhook to dev branch</task-title>
|
||||
|
|
@ -205,6 +189,22 @@
|
|||
<div class="lane">
|
||||
<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-title>CI deploy email notification</task-title>
|
||||
<task-content>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,48 @@
|
|||
|
||||
</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>
|
||||
<h2>Key decisions</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="card">
|
||||
<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 class="card">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<header>
|
||||
<p class="date">Project Documentation</p>
|
||||
<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>
|
||||
|
||||
<section>
|
||||
|
|
|
|||
Loading…
Reference in New Issue