Update Server Auth FIx
This commit is contained in:
parent
403499341f
commit
1e8ec50f6e
|
|
@ -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() );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue