1026 lines
41 KiB
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 <accessToken></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": "<uuid>" }
|
|
|
|
// Success response HTTP 200
|
|
{
|
|
"accessToken": "<new signed JWT>",
|
|
"refreshToken": "<new uuid>" // 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<string, unknown>;
|
|
}
|
|
|
|
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<RefreshResult | null>
|
|
{
|
|
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<RefreshResult>;
|
|
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 =>
|
|
{
|
|
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( () => 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 ) =>
|
|
{
|
|
res.json( { userId: req.auth!.userId } );
|
|
} );
|
|
|
|
// Protect a page (redirect to login if not authenticated):
|
|
app.get( '/dashboard', requireAuthPage, ( req, res ) =>
|
|
{
|
|
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=<url></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
|
|
“authenticated” — 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 =>
|
|
{
|
|
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 =>
|
|
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 ) =>
|
|
{
|
|
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 ) =>
|
|
{
|
|
try
|
|
{
|
|
const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record<string, unknown>;
|
|
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<boolean>
|
|
{
|
|
if ( !currentTokens?.refreshToken ) return false;
|
|
try
|
|
{
|
|
const result = await postJson(
|
|
`${ AUTH_HOST }/api/auth/refresh`,
|
|
{ refreshToken: currentTokens.refreshToken }
|
|
) as Record<string, unknown>;
|
|
|
|
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<Response>
|
|
{
|
|
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 ) =>
|
|
{
|
|
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( () =>
|
|
{
|
|
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: () => 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 = '<paste token here>';
|
|
|
|
// 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 < new Date() );</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Test refresh manually</h3>
|
|
<pre><code># Replace <token> 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":"<token>"}' | 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":"<token>"}' | 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 <service> -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 — implementation guide
|
|
</footer>
|
|
|
|
</div>
|
|
<script>var NAV_ROOT = './';</script>
|
|
<script src="./_assets_/nav-data.js"></script>
|
|
<script src="./_assets_/nav.js"></script>
|
|
</body>
|
|
</html>
|