Guide
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.
Every authenticated session carries two tokens. Understanding their roles is required before touching any auth code.
userId, email, roles, products,
and settings. Services verify it locally with the shared
JWT_SECRET. Short-lived (1 hour
in production). No round-trip to rokojori-auth needed to verify it.
rokojori-auth/build/data/refreshTokens.json. Used to obtain a new
accessToken when the old one expires. Long-lived
(30 days). Rotation: each use
deletes the old record and creates a new one.
HttpOnly; Secure; SameSite=Lax cookies on .rokojori.com.
Every request from any subdomain automatically carries them. The browser manages them.
userData/tokens.json).
Must be attached to every request as
Authorization: Bearer <accessToken>.
If the access token is expired but a valid refresh token is present, the service must silently obtain a new access token and continue the request. No redirect. No visible disruption.
Only redirect to login when refresh itself fails — meaning both tokens are gone or
the refresh token has expired. Even then, API routes return
401 JSON; only page navigations redirect.
POST /api/auth/refresh on account.rokojori.com (or
AUTH_INTERNAL_HOST 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.
// 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" }
Important: 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.
rokojori-auth/source/server/routes/auth.ts line 13:
const ACCESS_TOKEN_TTL = '10s'; // ← BUG: should be '1h'
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 '1h'.
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.
rokojori-auth/source/server/middleware/requireAuth.ts
— sets req.auth. No transparent refresh. Intended only for
the auth service itself.
roject/source/server/middleware/auth.ts
— sets req.user (different name!). Has jwtMiddleware
with transparent refresh for API routes. Redirects for page routes.
Most complete implementation.
tunnel/source/server/middleware/requireAuth.ts
— sets req.auth. No transparent refresh. Expired tokens always
get 401, no recovery.
styles/source/server/middleware/requireAuth.ts
— sets req.auth. No transparent refresh. Same problem as tunnel.
roject/electron/main.ts
— intercepts the will-redirect event to catch the
/api/auth/refresh-session 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.
tunnel/electron-agent/main.ts
— has apiFetch() that retries on 401 after calling
tryRefreshTokens(). This is the correct pattern.
Roject uses req.user; every other service uses req.auth.
This means you cannot copy routes between services without changing the property
name. The canonical name going forward is req.auth
— it matches rokojori-auth itself and is more specific (avoids collision with
Passport.js conventions).
Every Express service (Roject, tunnel, styles, etc.) must have exactly this file:
source/server/middleware/auth.ts
It exports two functions: jwtMiddleware (extracts and optionally
refreshes the user) and requireAuth (guards routes, returns 401
if not authenticated). Use them in sequence.
# 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
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();
}
Apply jwtMiddleware globally before all routes and static files.
This guarantees req.auth is populated on every request where a
valid (or refreshable) token is present.
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( ... );
} );
jwtMiddleware reads accessToken cookie
or Authorization: Bearer header.next(), req.auth is undefined.req.auth set → next().refreshToken cookie present →
POST to AUTH_INTERNAL_HOST/api/auth/refresh.req.auth set
from new access token → next().next(),
req.auth remains undefined.requireAuth gate: req.auth undefined → 401 JSON.requireAuthPage gate: req.auth undefined → redirect to
AUTH_HOST/login.html?redirect=<url>.At no point is a redirect issued for a request that has a valid refresh token.
Use requireAccess when a service needs more than
“authenticated” — for example, only users with a specific product
or role. Place it after requireAuth. Superadmin bypasses all rules.
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' } );
}
};
}
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( ... );
} );
Store tokens in app.getPath('userData')/tokens.json.
Never store them in the renderer process or in cookies — Electron can't rely
on the browser cookie jar for its own API calls.
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 */ } }
Call POST account.rokojori.com/api/auth/login with email and password.
On success, save both tokens. Open the main window.
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 ) }; }
} );
All API calls from the Electron main process go through apiFetch().
It attaches the current access token, and on a 401 it transparently
refreshes once and retries. If refresh fails, it calls handleLogout().
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();
}
When Electron embeds a full web app on localhost, 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.
// 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 } );
}
);
With this in place, the Express server's jwtMiddleware reads the
Bearer header and handles transparent refresh server-side. The Electron main
process does not need to intercept
any redirects.
On startup, load saved tokens. If present, open the main window directly —
the server-side jwtMiddleware will refresh silently on the first
request if the access token has aged out. If no tokens, open the login window.
app.whenReady().then( () =>
{
currentTokens = loadTokens();
if ( currentTokens ) createMainWindow();
else createLoginWindow();
} );
Do not verify the saved access token at startup (it will likely be expired). Let the server-side middleware handle it.
The canonical auth.ts logs every step with the prefix
[auth]. To watch auth events live on any service:
# All [auth] log lines for roject.service
journalctl -u roject -f | grep '\[auth\]'
# Or for tunnel
journalctl -u tunnel-rokojori -f | grep '\[auth\]'
Expected flow when a token expires mid-session:
[auth] expired token on: GET /api/projects
[auth] tryRefresh → http://localhost:3001/api/auth/refresh
[auth] tryRefresh status: 200
[auth] tryRefresh: succeeded
Expected flow when refresh also fails (session must re-login):
[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
AUTH_INTERNAL_HOST is set to the correct internal address
(e.g. http://localhost:3001). If it falls back to the public URL,
the server-to-server call goes through nginx and may fail on TLS or routing.
ACCESS_TOKEN_TTL in rokojori-auth is still set to '10s'.
Change to '1h'.
requireAuth.ts (tunnel/styles pattern)
which has no jwtMiddleware. The token is refreshed but
req.auth is never set because the standalone requireAuth
only reads the (still-expired) original token.
jwtMiddleware is not registered before the route in index.ts,
or the route is using a Router that was created before app.use( jwtMiddleware ).
TunnelAgent or other WebSocket was constructed with
token: string (static) instead of getToken: () => string
(getter). The WebSocket reconnects with the stale token. Always use a getter.
COOKIE_DOMAIN in the service's .env. It must be
.rokojori.com (leading dot) to cover all subdomains. Verify with
DevTools → Application → Cookies.
jwt.verify throws with something other than
TokenExpiredError (e.g. JsonWebTokenError: invalid signature),
the service's JWT_SECRET does not match the one rokojori-auth
used to sign the token.
// 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() );
# 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
auth.ts from section 2 into
source/server/middleware/auth.ts.requireAuth.ts that does not have
jwtMiddleware — it is incomplete.source/server/index.ts: add app.use( cookieParser() )
and app.use( jwtMiddleware ) before all routes and static files.app.set( 'trust proxy', 1 ) so secure cookies and real client IPs work.JWT_SECRET, AUTH_HOST,
AUTH_INTERNAL_HOST, COOKIE_DOMAIN to .env
(and to the server .env).req.user with req.auth.requireAccess.ts (section 3) if the service needs product/role gating.journalctl -u <service> -f | grep '\[auth\]'
to verify the refresh flow fires correctly.ACCESS_TOKEN_TTL in
source/server/routes/auth.ts from '10s' to
'1h'.
index.ts page-level redirect (for account.rokojori.com
pages) is acceptable — it is a pure server-rendered auth service with no SPA
or Electron embedding. Leave it as-is.
source/server/middleware/requireAuth.ts is fine
for the auth service's own routes (/api/auth/me etc.) because
the auth service does not need to do server-side token refresh for itself.
source/server/middleware/auth.ts already has the correct
jwtMiddleware pattern. Replace req.user with
req.auth throughout all route files.
jwtMiddleware (the
if ( !isApiRequest( req ) ) branch). With server-side silent
refresh, page requests are also handled transparently — the redirect is no
longer needed.
will-redirect intercept in
electron/main.ts once the redirect branch is removed from
jwtMiddleware.
source/server/middleware/requireAuth.ts with the
canonical auth.ts. The tunnel server currently has no transparent
refresh — every request with an expired token gets 401.
cookieParser() and jwtMiddleware in
source/server/index.ts (for /api/tunnels routes).
electron-agent/main.ts) already uses the
correct apiFetch + tryRefreshTokens pattern — no changes needed there.
requireAuth.ts with the canonical
auth.ts and add jwtMiddleware to index.ts.
requireAccess.ts already has the correct HTML-redirect pattern —
keep it, but change req.auth references to confirm they match the
canonical property name (they already do in styles).
Once the canonical pattern is stable across all services, the repeated copy-paste
creates drift risk: a fix in one auth.ts must be manually applied to
all others. A shared npm package removes that risk.
// @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 };
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.
The existing shared library infrastructure (git submodule at
roject/shared/) could host this, or it can be a separate Gitea repo
referenced as an npm file dependency ("@rokojori/auth": "file:../auth-lib").
# 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
accessToken: 1 hour (JWT, verified locally with JWT_SECRET)
refreshToken: 30 days (opaque UUID, stored in rokojori-auth db)
source/server/middleware/auth.ts ← canonical jwtMiddleware + requireAuth
source/server/middleware/requireAccess.ts ← if product/role gating needed
Delete requireAuth.ts if it exists as a separate file.