rokojori-auth/workspace/implementation-guide.html

1026 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Implementation Guide — rokojori</title>
<link rel="stylesheet" href="./_assets_/styles.css">
<link rel="stylesheet" href="./_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Guide</p>
<h1>Auth Implementation Guide</h1>
<p class="subtitle">
The canonical pattern for integrating rokojori-auth into any service.
Covers server-side middleware, Electron apps, token refresh, failure handling,
and debugging. Every service should follow exactly this pattern.
</p>
</header>
<!-- ─── 0. Mental model ────────────────────────────────────────── -->
<section>
<h2>0 — Mental model</h2>
<div class="card">
<h3>Two tokens, two jobs</h3>
<p>
Every authenticated session carries two tokens. Understanding their roles
is required before touching any auth code.
</p>
<ul style="margin-top:0.75rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">accessToken</strong> — a signed JWT containing
<code>userId</code>, <code>email</code>, <code>roles</code>, <code>products</code>,
and <code>settings</code>. Services verify it locally with the shared
<code>JWT_SECRET</code>. Short-lived (<strong style="color:var(--text)">1 hour</strong>
in production). No round-trip to rokojori-auth needed to verify it.
</li>
<li>
<strong style="color:var(--text)">refreshToken</strong> — an opaque UUID stored in
<code>rokojori-auth/build/data/refreshTokens.json</code>. Used to obtain a new
accessToken when the old one expires. Long-lived
(<strong style="color:var(--text)">30 days</strong>). Rotation: each use
deletes the old record and creates a new one.
</li>
</ul>
</div>
<div class="card">
<h3>Token storage by client type</h3>
<ul style="margin-top:0.5rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">Browser (cookie)</strong> — both tokens are set as
<code>HttpOnly; Secure; SameSite=Lax</code> cookies on <code>.rokojori.com</code>.
Every request from any subdomain automatically carries them. The browser manages them.
</li>
<li>
<strong style="color:var(--text)">Electron / native</strong> — tokens are returned in
the login response body and stored locally (e.g. <code>userData/tokens.json</code>).
Must be attached to every request as
<code>Authorization: Bearer &lt;accessToken&gt;</code>.
</li>
</ul>
</div>
<div class="card">
<h3>The rule: never redirect to refresh</h3>
<p>
If the access token is expired <strong style="color:var(--text)">but a valid refresh
token is present</strong>, the service must silently obtain a new access token and
continue the request. <strong style="color:var(--text)">No redirect. No visible
disruption.</strong>
</p>
<p style="margin-top:0.75rem">
Only redirect to login when refresh itself fails — meaning both tokens are gone or
the refresh token has expired. Even then, API routes return
<code>401 JSON</code>; only page navigations redirect.
</p>
</div>
<div class="card">
<h3>The refresh endpoint</h3>
<p>
<code>POST /api/auth/refresh</code> on <code>account.rokojori.com</code> (or
<code>AUTH_INTERNAL_HOST</code> when called server-to-server).
Send the refreshToken in the JSON body. On success it returns new cookies
(for browser clients) and a JSON body with both new tokens.
</p>
<pre><code>// Request
POST /api/auth/refresh
Content-Type: application/json
{ "refreshToken": "&lt;uuid&gt;" }
// Success response HTTP 200
{
"accessToken": "&lt;new signed JWT&gt;",
"refreshToken": "&lt;new uuid&gt;" // old one is now invalid
}
// Failure response HTTP 401
{ "error": "Invalid or expired refresh token" }</code></pre>
<p style="margin-top:0.75rem">
<strong style="color:var(--text)">Important:</strong> the old refreshToken is deleted
immediately when used. If the network drops after the server responds but before the
client saves the new token, the session is lost. This is a deliberate security
trade-off — do not retry a refresh call without receiving a fresh token.
</p>
</div>
</section>
<!-- ─── 1. Current state + known bugs ─────────────────────────── -->
<section>
<h2>1 — Current state and known bugs</h2>
<div class="card">
<h3>ACCESS_TOKEN_TTL is set to 10 seconds in production</h3>
<p>
<code>rokojori-auth/source/server/routes/auth.ts</code> line 13:
</p>
<pre><code>const ACCESS_TOKEN_TTL = '10s'; // ← BUG: should be '1h'</code></pre>
<p style="margin-top:0.75rem">
Every access token expires 10 seconds after login. Every API call after that
depends on the transparent refresh working flawlessly. This is the single most
likely cause of constant auth breakage. Change this to <code>'1h'</code>.
</p>
</div>
<div class="card">
<h3>Four different middleware implementations</h3>
<p>
The following files are mostly copies of each other with subtle differences.
They diverge on: the property name for the decoded payload, whether they do
transparent refresh, and how they handle expired tokens.
</p>
<ul style="margin-top:0.75rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<code>rokojori-auth/source/server/middleware/requireAuth.ts</code>
— sets <code>req.auth</code>. No transparent refresh. Intended only for
the auth service itself.
</li>
<li>
<code>roject/source/server/middleware/auth.ts</code>
— sets <code>req.user</code> (different name!). Has <code>jwtMiddleware</code>
with transparent refresh for API routes. Redirects for page routes.
Most complete implementation.
</li>
<li>
<code>tunnel/source/server/middleware/requireAuth.ts</code>
— sets <code>req.auth</code>. No transparent refresh. Expired tokens always
get <code>401</code>, no recovery.
</li>
<li>
<code>styles/source/server/middleware/requireAuth.ts</code>
— sets <code>req.auth</code>. No transparent refresh. Same problem as tunnel.
</li>
</ul>
</div>
<div class="card">
<h3>Three different Electron refresh implementations</h3>
<ul style="margin-top:0.5rem;line-height:1.9;font-size:0.9rem;color:var(--muted)">
<li>
<code>roject/electron/main.ts</code>
— intercepts the <code>will-redirect</code> event to catch the
<code>/api/auth/refresh-session</code> redirect, then refreshes. This is a
workaround for the redirect-based flow. It works but couples the Electron app
to redirect behaviour that should not exist.
</li>
<li>
<code>tunnel/electron-agent/main.ts</code>
— has <code>apiFetch()</code> that retries on <code>401</code> after calling
<code>tryRefreshTokens()</code>. This is the correct pattern.
</li>
</ul>
</div>
<div class="card">
<h3>req.auth vs req.user</h3>
<p>
Roject uses <code>req.user</code>; every other service uses <code>req.auth</code>.
This means you cannot copy routes between services without changing the property
name. The canonical name going forward is <strong style="color:var(--text)">req.auth</strong>
— it matches rokojori-auth itself and is more specific (avoids collision with
Passport.js conventions).
</p>
</div>
</section>
<!-- ─── 2. Canonical server-side middleware ────────────────────── -->
<section>
<h2>2 — Canonical server-side middleware</h2>
<div class="card">
<h3>File location</h3>
<p>
Every Express service (Roject, tunnel, styles, etc.) must have exactly this file:
</p>
<pre><code>source/server/middleware/auth.ts</code></pre>
<p style="margin-top:0.75rem">
It exports two functions: <code>jwtMiddleware</code> (extracts and optionally
refreshes the user) and <code>requireAuth</code> (guards routes, returns 401
if not authenticated). Use them in sequence.
</p>
</div>
<div class="card">
<h3>Environment variables required</h3>
<pre><code># Shared secret — must match rokojori-auth JWT_SECRET exactly
JWT_SECRET=...
# Public URL of the auth service (used for page redirects to login)
AUTH_HOST=https://account.rokojori.com
# Internal URL for server-to-server refresh calls.
# In production: set to http://localhost:3001 to bypass nginx TLS overhead.
# In dev: leave unset — falls back to AUTH_HOST.
AUTH_INTERNAL_HOST=http://localhost:3001
# Cookie domain — must match rokojori-auth COOKIE_DOMAIN
COOKIE_DOMAIN=.rokojori.com</code></pre>
</div>
<div class="card">
<h3>The canonical auth.ts</h3>
<pre><code>import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
// ── Shared type (same shape as rokojori-auth JWT payload) ─────────────
export interface AuthPayload
{
userId: string;
email: string;
roles: string[];
products: string[];
settings: Record&lt;string, unknown&gt;;
}
declare global
{
namespace Express
{
interface Request { auth?: AuthPayload; }
}
}
// ── Config ─────────────────────────────────────────────────────────────
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 COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
// ── Helpers ────────────────────────────────────────────────────────────
function extractToken( req: Request ): string | undefined
{
const cookie = req.cookies?.accessToken as string | undefined;
if ( cookie ) return cookie;
const header = req.headers.authorization;
if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 );
return undefined;
}
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&lt;RefreshResult | null&gt;
{
const url = `${ AUTH_INTERNAL_HOST }/api/auth/refresh`;
console.log( '[auth] tryRefresh →', url );
try
{
const r = await fetch( url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { refreshToken } ),
} );
console.log( '[auth] tryRefresh status:', r.status );
if ( !r.ok )
{
const body = await r.text();
console.log( '[auth] tryRefresh error body:', body );
return null;
}
const data = await r.json() as Partial&lt;RefreshResult&gt;;
if ( !data.accessToken || !data.refreshToken )
{
console.log( '[auth] tryRefresh: missing tokens in response:', Object.keys( data ) );
return null;
}
console.log( '[auth] tryRefresh: succeeded' );
return { accessToken: data.accessToken, refreshToken: data.refreshToken };
}
catch ( err )
{
console.log( '[auth] tryRefresh: fetch error:', err );
return null;
}
}
// ── jwtMiddleware ──────────────────────────────────────────────────────
//
// Must be registered BEFORE requireAuth and before express.static.
// Sets req.auth when a valid (or successfully refreshed) token is present.
// Never redirects — any redirection is deferred to requireAuth or requireAccess.
export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void
{
const token = extractToken( req );
if ( !token ) { next(); return; }
try
{
req.auth = jwt.verify( token, JWT_SECRET ) as AuthPayload;
next();
return;
}
catch ( err: unknown )
{
// Token malformed or wrong secret — treat as unauthenticated
if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; }
}
// Access token expired — attempt silent refresh with the refresh token
console.log( '[auth] expired token on:', req.method, req.path );
const refreshToken = req.cookies?.refreshToken as string | undefined;
if ( !refreshToken )
{
// Browser client with no refresh cookie, or Electron with only an expired Bearer.
// Nothing to do — fall through as unauthenticated.
console.log( '[auth] no refreshToken available — cannot refresh' );
next();
return;
}
tryRefresh( refreshToken ).then( result =&gt;
{
if ( !result )
{
// Refresh token itself is expired or revoked — fall through as unauthenticated
console.log( '[auth] refresh failed for:', req.method, req.path );
next();
return;
}
// Rotate cookies on the response so the browser picks up the new pair
res.cookie( 'accessToken', result.accessToken, cookieOpts( 60 * 60 * 1000 ) );
res.cookie( 'refreshToken', result.refreshToken, cookieOpts( 30 * 24 * 60 * 60 * 1000 ) );
try
{
req.auth = jwt.verify( result.accessToken, JWT_SECRET ) as AuthPayload;
}
catch
{
// The new token failed verification — highly unexpected but safe to fall through
console.log( '[auth] unexpected: new access token failed verification' );
}
next();
} ).catch( () =&gt; next() );
}
// ── requireAuth ────────────────────────────────────────────────────────
//
// Guards API routes. Always returns JSON — never redirects.
// Place after jwtMiddleware on the route or router.
export function requireAuth( req: Request, res: Response, next: NextFunction ): void
{
if ( !req.auth )
{
res.status( 401 ).json( { error: 'Not authenticated' } );
return;
}
next();
}
// ── requireAuthPage ────────────────────────────────────────────────────
//
// Guards server-rendered pages. Redirects to login when not authenticated.
// Only use this on routes that serve HTML pages, never on API routes.
export function requireAuthPage( req: Request, res: Response, next: NextFunction ): void
{
if ( !req.auth )
{
const here = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl );
res.redirect( `${ AUTH_HOST }/login.html?redirect=${ here }` );
return;
}
next();
}</code></pre>
</div>
<div class="card">
<h3>Wiring in index.ts</h3>
<p>
Apply <code>jwtMiddleware</code> globally before all routes and static files.
This guarantees <code>req.auth</code> is populated on every request where a
valid (or refreshable) token is present.
</p>
<pre><code>import { jwtMiddleware, requireAuth } from './middleware/auth';
app.set( 'trust proxy', 1 ); // required for real client IPs and secure cookies
app.use( express.json() );
app.use( cookieParser() );
app.use( jwtMiddleware ); // ← runs on every request
app.use( express.static( ... ) ); // static files now see req.auth too
// Protect an API route:
app.get( '/api/me', requireAuth, ( req, res ) =&gt;
{
res.json( { userId: req.auth!.userId } );
} );
// Protect a page (redirect to login if not authenticated):
app.get( '/dashboard', requireAuthPage, ( req, res ) =&gt;
{
res.sendFile( ... );
} );</code></pre>
</div>
<div class="card">
<h3>What happens step by step</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>Request arrives. <code>jwtMiddleware</code> reads <code>accessToken</code> cookie
or <code>Authorization: Bearer</code> header.</li>
<li>No token → <code>next()</code>, <code>req.auth</code> is <code>undefined</code>.</li>
<li>Token present and valid → <code>req.auth</code> set → <code>next()</code>.</li>
<li>Token expired and <code>refreshToken</code> cookie present →
POST to <code>AUTH_INTERNAL_HOST/api/auth/refresh</code>.</li>
<li>Refresh succeeds → new cookies written on response, <code>req.auth</code> set
from new access token → <code>next()</code>.</li>
<li>Refresh fails (or no refresh token) → <code>next()</code>,
<code>req.auth</code> remains <code>undefined</code>.</li>
<li><code>requireAuth</code> gate: <code>req.auth</code> undefined → <code>401 JSON</code>.</li>
<li><code>requireAuthPage</code> gate: <code>req.auth</code> undefined → redirect to
<code>AUTH_HOST/login.html?redirect=&lt;url&gt;</code>.</li>
</ol>
<p style="margin-top:0.75rem">
At no point is a redirect issued for a request that has a valid refresh token.
</p>
</div>
</section>
<!-- ─── 3. requireAccess (product/role gating) ─────────────────── -->
<section>
<h2>3 — Per-service access rules (requireAccess)</h2>
<div class="card">
<h3>When to use</h3>
<p>
Use <code>requireAccess</code> when a service needs more than
&ldquo;authenticated&rdquo; — for example, only users with a specific product
or role. Place it after <code>requireAuth</code>. Superadmin bypasses all rules.
</p>
</div>
<div class="card">
<h3>requireAccess.ts</h3>
<pre><code>import { Request, Response, NextFunction } from 'express';
// Copy the AuthPayload import from auth.ts if in the same service,
// or re-declare the fields you need.
export type AccessRule = { role: string; product?: string };
// AND within a rule, OR across rules, superadmin always passes.
export function requireAccess( rules: AccessRule[] )
{
return ( req: Request, res: Response, next: NextFunction ): void =&gt;
{
const auth = req.auth;
if ( !auth )
{
// jwtMiddleware already ran — if we're here without req.auth the token
// is gone. API: 401. Page: redirect.
if ( req.accepts( 'html' ) )
{
const here = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl );
res.redirect( `${ process.env.AUTH_HOST ?? 'https://account.rokojori.com' }/login.html?redirect=${ here }` );
}
else
{
res.status( 401 ).json( { error: 'Not authenticated' } );
}
return;
}
if ( auth.roles.includes( 'superadmin' ) ) { next(); return; }
const allowed = rules.some( rule =&gt;
auth.roles.includes( rule.role ) &&
( !rule.product || auth.products.includes( rule.product ) )
);
if ( allowed ) { next(); return; }
if ( req.accepts( 'html' ) )
{
res.redirect( '/' );
}
else
{
res.status( 403 ).json( { error: 'Forbidden' } );
}
};
}</code></pre>
</div>
<div class="card">
<h3>Example — styles service</h3>
<pre><code>import { requireAccess } from './middleware/requireAccess';
const STYLES_RULES = [
{ role: 'admin' },
{ role: 'user', product: 'styles' },
{ role: 'user', product: 'premium' },
];
// API route
app.get( '/api/fonts', requireAuth, requireAccess( STYLES_RULES ), fontsHandler );
// Page route (redirect on 401/403)
app.get( '/dashboard.html', requireAccess( STYLES_RULES ), ( req, res ) =&gt;
{
res.sendFile( ... );
} );</code></pre>
</div>
</section>
<!-- ─── 4. Electron ────────────────────────────────────────────── -->
<section>
<h2>4 — Electron main process auth</h2>
<div class="card">
<h3>Storage</h3>
<p>
Store tokens in <code>app.getPath('userData')/tokens.json</code>.
Never store them in the renderer process or in cookies — Electron can't rely
on the browser cookie jar for its own API calls.
</p>
<pre><code>interface Tokens { accessToken: string; refreshToken: string; }
function tokenFile(): string { return path.join( app.getPath( 'userData' ), 'tokens.json' ); }
function loadTokens(): Tokens | null { try { return JSON.parse( fs.readFileSync( tokenFile(), 'utf-8' ) ); } catch { return null; } }
function saveTokens( t: Tokens ): void { fs.writeFileSync( tokenFile(), JSON.stringify( t ), 'utf-8' ); }
function clearTokens(): void { try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ } }</code></pre>
</div>
<div class="card">
<h3>Login</h3>
<p>
Call <code>POST account.rokojori.com/api/auth/login</code> with email and password.
On success, save both tokens. Open the main window.
</p>
<pre><code>ipcMain.handle( 'auth:login', async ( _e, email: string, password: string ) =&gt;
{
try
{
const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record&lt;string, unknown&gt;;
if ( result.accessToken && result.refreshToken )
{
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
saveTokens( currentTokens );
return { ok: true };
}
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
}
catch ( err ) { return { ok: false, error: String( err ) }; }
} );</code></pre>
</div>
<div class="card">
<h3>apiFetch — the canonical request wrapper</h3>
<p>
All API calls from the Electron main process go through <code>apiFetch()</code>.
It attaches the current access token, and on a <code>401</code> it transparently
refreshes once and retries. If refresh fails, it calls <code>handleLogout()</code>.
</p>
<pre><code>let currentTokens: Tokens | null = null;
async function tryRefreshTokens(): Promise&lt;boolean&gt;
{
if ( !currentTokens?.refreshToken ) return false;
try
{
const result = await postJson(
`${ AUTH_HOST }/api/auth/refresh`,
{ refreshToken: currentTokens.refreshToken }
) as Record&lt;string, unknown&gt;;
if ( result.accessToken && result.refreshToken )
{
currentTokens = {
accessToken: result.accessToken as string,
refreshToken: result.refreshToken as string,
};
saveTokens( currentTokens );
return true;
}
return false;
}
catch { return false; }
}
async function apiFetch( apiPath: string, options: RequestInit = {}, isRetry = false ): Promise&lt;Response&gt;
{
const url = `${ SERVICE_URL }${ apiPath }`;
const res = await fetch( url,
{
...options,
headers:
{
'Content-Type': 'application/json',
'Authorization': `Bearer ${ currentTokens?.accessToken ?? '' }`,
...( options.headers ?? {} ),
},
} );
if ( res.status === 401 && !isRetry )
{
const refreshed = await tryRefreshTokens();
if ( refreshed ) return apiFetch( apiPath, options, true ); // retry once with new token
handleLogout(); // refresh failed — force re-login
}
return res;
}
function handleLogout(): void
{
clearTokens();
currentTokens = null;
// Stop any background work (WebSocket agents, timers, etc.)
mainWindow?.close();
if ( !loginWindow ) createLoginWindow();
}</code></pre>
</div>
<div class="card">
<h3>Header injection for embedded webview (Roject Electron only)</h3>
<p>
When Electron embeds a full web app on <code>localhost</code>, inject the access
token as a Bearer header on every request so the embedded Express server can
authenticate it. Use a getter function so reconnects after refresh always use
the current token.
</p>
<pre><code>// Register BEFORE opening the main window
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: [ `http://localhost:${ PORT }/*` ] },
( details, callback ) =&gt;
{
const token = currentTokens?.accessToken ?? null;
const headers = { ...details.requestHeaders };
if ( token ) headers[ 'Authorization' ] = `Bearer ${ token }`;
callback( { requestHeaders: headers } );
}
);</code></pre>
<p style="margin-top:0.75rem">
With this in place, the Express server's <code>jwtMiddleware</code> reads the
Bearer header and handles transparent refresh server-side. The Electron main
process does <strong style="color:var(--text)">not</strong> need to intercept
any redirects.
</p>
</div>
<div class="card">
<h3>Startup — check saved tokens</h3>
<p>
On startup, load saved tokens. If present, open the main window directly —
the server-side <code>jwtMiddleware</code> will refresh silently on the first
request if the access token has aged out. If no tokens, open the login window.
</p>
<pre><code>app.whenReady().then( () =&gt;
{
currentTokens = loadTokens();
if ( currentTokens ) createMainWindow();
else createLoginWindow();
} );</code></pre>
<p style="margin-top:0.75rem">
Do <strong style="color:var(--text)">not</strong> verify the saved access token
at startup (it will likely be expired). Let the server-side middleware handle it.
</p>
</div>
</section>
<!-- ─── 5. Debugging ───────────────────────────────────────────── -->
<section>
<h2>5 — Debugging</h2>
<div class="card">
<h3>Console log prefix</h3>
<p>
The canonical <code>auth.ts</code> logs every step with the prefix
<code>[auth]</code>. To watch auth events live on any service:
</p>
<pre><code># All [auth] log lines for roject.service
journalctl -u roject -f | grep '\[auth\]'
# Or for tunnel
journalctl -u tunnel-rokojori -f | grep '\[auth\]'</code></pre>
<p style="margin-top:0.75rem">Expected flow when a token expires mid-session:</p>
<pre><code>[auth] expired token on: GET /api/projects
[auth] tryRefresh → http://localhost:3001/api/auth/refresh
[auth] tryRefresh status: 200
[auth] tryRefresh: succeeded</code></pre>
<p style="margin-top:0.75rem">Expected flow when refresh also fails (session must re-login):</p>
<pre><code>[auth] expired token on: GET /api/projects
[auth] tryRefresh → http://localhost:3001/api/auth/refresh
[auth] tryRefresh status: 401
[auth] tryRefresh error body: {"error":"Invalid or expired refresh token"}
[auth] refresh failed for: GET /api/projects</code></pre>
</div>
<div class="card">
<h3>Common failure modes</h3>
<ul style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
<strong style="color:var(--text)">tryRefresh always returns null</strong>
— check <code>AUTH_INTERNAL_HOST</code> is set to the correct internal address
(e.g. <code>http://localhost:3001</code>). If it falls back to the public URL,
the server-to-server call goes through nginx and may fail on TLS or routing.
</li>
<li>
<strong style="color:var(--text)">401 every 10 seconds</strong>
<code>ACCESS_TOKEN_TTL</code> in rokojori-auth is still set to <code>'10s'</code>.
Change to <code>'1h'</code>.
</li>
<li>
<strong style="color:var(--text)">Refresh succeeds but user still gets 401</strong>
— the route is using the old <code>requireAuth.ts</code> (tunnel/styles pattern)
which has no <code>jwtMiddleware</code>. The token is refreshed but
<code>req.auth</code> is never set because the standalone <code>requireAuth</code>
only reads the (still-expired) original token.
</li>
<li>
<strong style="color:var(--text)">req.auth is undefined even with a valid token</strong>
<code>jwtMiddleware</code> is not registered before the route in <code>index.ts</code>,
or the route is using a Router that was created before <code>app.use( jwtMiddleware )</code>.
</li>
<li>
<strong style="color:var(--text)">Electron: Unauthorized after long idle</strong>
<code>TunnelAgent</code> or other WebSocket was constructed with
<code>token: string</code> (static) instead of <code>getToken: () =&gt; string</code>
(getter). The WebSocket reconnects with the stale token. Always use a getter.
</li>
<li>
<strong style="color:var(--text)">Cookie not sent on API requests</strong>
— check <code>COOKIE_DOMAIN</code> in the service's <code>.env</code>. It must be
<code>.rokojori.com</code> (leading dot) to cover all subdomains. Verify with
DevTools → Application → Cookies.
</li>
<li>
<strong style="color:var(--text)">JWT_SECRET mismatch</strong>
— if <code>jwt.verify</code> throws with something other than
<code>TokenExpiredError</code> (e.g. <code>JsonWebTokenError: invalid signature</code>),
the service's <code>JWT_SECRET</code> does not match the one rokojori-auth
used to sign the token.
</li>
</ul>
</div>
<div class="card">
<h3>Decode a JWT without verifying (for inspection)</h3>
<pre><code>// In Node.js REPL or a script:
const jwt = require('jsonwebtoken');
const token = '&lt;paste token here&gt;';
// Decode without verification — shows the payload and expiry
console.log( jwt.decode( token, { complete: true } ) );
// → { header: { alg: 'HS256' }, payload: { userId, email, roles, exp, iat }, signature }
// Check expiry:
const payload = jwt.decode( token );
const expiresAt = new Date( payload.exp * 1000 );
console.log( 'Expires at:', expiresAt, '— expired:', expiresAt &lt; new Date() );</code></pre>
</div>
<div class="card">
<h3>Test refresh manually</h3>
<pre><code># Replace &lt;token&gt; with a value from the refreshToken cookie in DevTools
curl -s -X POST https://account.rokojori.com/api/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"&lt;token&gt;"}' | jq
# Expected: { accessToken: "...", refreshToken: "..." }
# Failure: { error: "Invalid or expired refresh token" }
# Test internal server-to-server call (run on the server):
curl -s -X POST http://localhost:3001/api/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refreshToken":"&lt;token&gt;"}' | jq</code></pre>
</div>
</section>
<!-- ─── 6. Service checklist ───────────────────────────────────── -->
<section>
<h2>6 — Per-service migration checklist</h2>
<div class="card">
<h3>When adding auth to a new service or fixing an existing one</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>Copy the canonical <code>auth.ts</code> from section 2 into
<code>source/server/middleware/auth.ts</code>.</li>
<li>Delete any existing <code>requireAuth.ts</code> that does not have
<code>jwtMiddleware</code> — it is incomplete.</li>
<li>In <code>source/server/index.ts</code>: add <code>app.use( cookieParser() )</code>
and <code>app.use( jwtMiddleware )</code> before all routes and static files.</li>
<li>Set <code>app.set( 'trust proxy', 1 )</code> so secure cookies and real client IPs work.</li>
<li>Add <code>JWT_SECRET</code>, <code>AUTH_HOST</code>,
<code>AUTH_INTERNAL_HOST</code>, <code>COOKIE_DOMAIN</code> to <code>.env</code>
(and to the server <code>.env</code>).</li>
<li>Replace all uses of <code>req.user</code> with <code>req.auth</code>.</li>
<li>Add <code>requireAccess.ts</code> (section 3) if the service needs product/role gating.</li>
<li>Deploy and watch <code>journalctl -u &lt;service&gt; -f | grep '\[auth\]'</code>
to verify the refresh flow fires correctly.</li>
</ol>
</div>
<div class="card">
<h3>Fix rokojori-auth (the auth service itself)</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
Change <code>ACCESS_TOKEN_TTL</code> in
<code>source/server/routes/auth.ts</code> from <code>'10s'</code> to
<code>'1h'</code>.
</li>
<li>
The <code>index.ts</code> page-level redirect (for <code>account.rokojori.com</code>
pages) is acceptable — it is a pure server-rendered auth service with no SPA
or Electron embedding. Leave it as-is.
</li>
<li>
The standalone <code>source/server/middleware/requireAuth.ts</code> is fine
for the auth service's own routes (<code>/api/auth/me</code> etc.) because
the auth service does not need to do server-side token refresh for itself.
</li>
</ol>
</div>
<div class="card">
<h3>Fix Roject server</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
<code>source/server/middleware/auth.ts</code> already has the correct
<code>jwtMiddleware</code> pattern. Replace <code>req.user</code> with
<code>req.auth</code> throughout all route files.
</li>
<li>
Remove the non-API redirect in <code>jwtMiddleware</code> (the
<code>if ( !isApiRequest( req ) )</code> branch). With server-side silent
refresh, page requests are also handled transparently — the redirect is no
longer needed.
</li>
<li>
Remove the <code>will-redirect</code> intercept in
<code>electron/main.ts</code> once the redirect branch is removed from
<code>jwtMiddleware</code>.
</li>
</ol>
</div>
<div class="card">
<h3>Fix tunnel server</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
Replace <code>source/server/middleware/requireAuth.ts</code> with the
canonical <code>auth.ts</code>. The tunnel server currently has no transparent
refresh — every request with an expired token gets <code>401</code>.
</li>
<li>
Add <code>cookieParser()</code> and <code>jwtMiddleware</code> in
<code>source/server/index.ts</code> (for <code>/api/tunnels</code> routes).
</li>
<li>
The Tunnel Electron app (<code>electron-agent/main.ts</code>) already uses the
correct <code>apiFetch</code> + <code>tryRefreshTokens</code> pattern — no changes needed there.
</li>
</ol>
</div>
<div class="card">
<h3>Fix styles server</h3>
<ol style="margin-top:0.5rem;line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
Same as tunnel: replace <code>requireAuth.ts</code> with the canonical
<code>auth.ts</code> and add <code>jwtMiddleware</code> to <code>index.ts</code>.
</li>
<li>
<code>requireAccess.ts</code> already has the correct HTML-redirect pattern —
keep it, but change <code>req.auth</code> references to confirm they match the
canonical property name (they already do in styles).
</li>
</ol>
</div>
</section>
<!-- ─── 7. Future shared library ──────────────────────────────── -->
<section>
<h2>7 — Future: shared auth library</h2>
<div class="card">
<h3>Why</h3>
<p>
Once the canonical pattern is stable across all services, the repeated copy-paste
creates drift risk: a fix in one <code>auth.ts</code> must be manually applied to
all others. A shared npm package removes that risk.
</p>
</div>
<div class="card">
<h3>What it would export</h3>
<pre><code>// @rokojori/auth-middleware (hypothetical package)
export type { AuthPayload };
// Server middleware
export { jwtMiddleware }; // transparent refresh, sets req.auth
export { requireAuth }; // API guard → 401 JSON
export { requireAuthPage }; // page guard → redirect to login
export { requireAccess }; // product/role gating
// Electron helpers
export { apiFetch }; // 401-retry wrapper
export { tryRefreshTokens };
export { handleLogout };
// Shared config types
export type { AccessRule };
export type { Tokens };</code></pre>
</div>
<div class="card">
<h3>When to do it</h3>
<p>
After every service is running the canonical pattern from this guide without issues.
The library is a consolidation step, not a fix step — don't introduce it while
auth is still broken in individual services.
</p>
<p style="margin-top:0.75rem">
The existing shared library infrastructure (git submodule at
<code>roject/shared/</code>) could host this, or it can be a separate Gitea repo
referenced as an npm file dependency (<code>"@rokojori/auth": "file:../auth-lib"</code>).
</p>
</div>
</section>
<!-- ─── Reference ────────────────────────────────────────────────── -->
<section>
<h2>Quick reference</h2>
<div class="card">
<h3>Environment variables</h3>
<pre><code># Required on every service that integrates auth
JWT_SECRET=... # must match rokojori-auth exactly
AUTH_HOST=https://account.rokojori.com
AUTH_INTERNAL_HOST=http://localhost:3001 # server-to-server; omit in dev
COOKIE_DOMAIN=.rokojori.com
# Required on rokojori-auth only
ACCESS_TOKEN_TTL=1h # IMPORTANT: must not be 10s
REFRESH_TOKEN_DAYS=30 # currently hardcoded; move to env if needed</code></pre>
</div>
<div class="card">
<h3>Endpoints on account.rokojori.com</h3>
<div class="tags">
<span class="tag">POST /api/auth/login</span>
<span class="tag">POST /api/auth/register</span>
<span class="tag">POST /api/auth/logout</span>
<span class="tag">POST /api/auth/refresh — body: { refreshToken }</span>
<span class="tag">GET /api/auth/me — requires valid accessToken</span>
</div>
</div>
<div class="card">
<h3>Token lifetimes</h3>
<pre><code>accessToken: 1 hour (JWT, verified locally with JWT_SECRET)
refreshToken: 30 days (opaque UUID, stored in rokojori-auth db)</code></pre>
</div>
<div class="card">
<h3>Files to create or replace in each service</h3>
<pre><code>source/server/middleware/auth.ts ← canonical jwtMiddleware + requireAuth
source/server/middleware/requireAccess.ts ← if product/role gating needed</code></pre>
<p style="margin-top:0.5rem">Delete <code>requireAuth.ts</code> if it exists as a separate file.</p>
</div>
</section>
<footer>
rokojori-auth &mdash; implementation guide
</footer>
</div>
<script>var NAV_ROOT = './';</script>
<script src="./_assets_/nav-data.js"></script>
<script src="./_assets_/nav.js"></script>
</body>
</html>