diff --git a/source/server/routes/auth.ts b/source/server/routes/auth.ts index d427d6c..6f1422a 100644 --- a/source/server/routes/auth.ts +++ b/source/server/routes/auth.ts @@ -9,7 +9,7 @@ import { isSuperAdmin } from '../roles'; const router = Router(); -const ACCESS_TOKEN_TTL = '10s'; +const ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL ?? '1h'; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js index 0e05cfb..8be262b 100644 --- a/workspace/_assets_/nav-data.js +++ b/workspace/_assets_/nav-data.js @@ -1,5 +1,9 @@ var NAV_DATA = { title: 'Workspace', path: 'index.html', - children: [] + children: [ + { title: 'Implementation Guide', path: 'implementation-guide.html' }, + { title: 'Non-Browser Implementation Guide', path: 'non-browser-implementation-guide.html' }, + { title: 'Add a Subdomain', path: 'add-subdomain.html' }, + ] }; diff --git a/workspace/implementation-guide.html b/workspace/implementation-guide.html new file mode 100644 index 0000000..47757c3 --- /dev/null +++ b/workspace/implementation-guide.html @@ -0,0 +1,1025 @@ + + +
+ + +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.
Guide
++ How to integrate rokojori-auth from any non-browser client — Godot, CLI tools, + native apps, scripts. No cookies. No redirects. Pure HTTP. + Read the Implementation Guide first + for the mental model and token lifecycle. +
+
+ The server sets HttpOnly cookies for browser clients automatically.
+ A non-browser HTTP client ignores Set-Cookie response headers unless
+ explicitly programmed to store and re-send them. Do
+ not use cookies. Use the response body instead — login and refresh both
+ return the tokens as JSON.
+
+ The server never redirects a well-formed non-browser client. Your client should
+ treat any 3xx response as a bug in its request, not something to follow.
+ Token refresh is a direct POST that returns new tokens immediately.
+
+ Your client is responsible for three things the browser does automatically: +
+Authorization: Bearer <accessToken>.401 response, refreshing transparently, and retrying
+ the original request once with the new token.https://account.rokojori.com
+
+ All requests use Content-Type: application/json and expect
+ a JSON response body.
+
Exchange credentials for a token pair. This is the entry point for all sessions.
+// Request
+POST https://account.rokojori.com/api/auth/login
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "password": "hunter2"
+}
+
+// Success HTTP 200
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiJ9...", // signed JWT, expires in 1 hour
+ "refreshToken": "a3f8c2d1-..." // opaque UUID, valid for 30 days
+}
+
+// Failure HTTP 401
+{ "error": "Invalid credentials" }
+
+// Rate limited HTTP 429
+{ "error": "Too many attempts. Try again later." }
+ + Exchange an old refreshToken for a new token pair. + The old refreshToken is invalidated immediately + — save the new tokens before making any other requests. +
+// Request
+POST https://account.rokojori.com/api/auth/refresh
+Content-Type: application/json
+
+{
+ "refreshToken": "a3f8c2d1-..." // from your local storage
+}
+
+// Success HTTP 200
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiJ9...", // new JWT
+ "refreshToken": "b7e1a4f2-..." // new UUID — save this, old one is gone
+}
+
+// Failure HTTP 401
+{ "error": "Invalid or expired refresh token" }
+// → session is over, user must log in again
+ + Revokes the refresh token on the server and ends the session. + Always call this on explicit user logout so the refresh token cannot be reused. +
+// Request
+POST https://account.rokojori.com/api/auth/logout
+Content-Type: application/json
+
+{
+ "refreshToken": "a3f8c2d1-..."
+}
+
+// Response HTTP 200
+{ "ok": true }
+
+// Also succeeds if the token is already gone — always returns 200
+ Read the current user's profile. Requires a valid access token.
+// Request
+GET https://account.rokojori.com/api/auth/me
+Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
+
+// Success HTTP 200
+{
+ "id": "uuid",
+ "email": "user@example.com",
+ "roles": ["user"],
+ "products": [{ "id": "roject-pro", "acquiredAt": "2026-07-13", "source": "polar" }],
+ "settings": { "theme": "dark" }
+}
+
+// Expired or missing token HTTP 401
+{ "error": "Not authenticated" }
+
+ In practice you rarely need to call /me — the JWT access token
+ already contains userId, roles, and products
+ in its payload. Decode it locally to read them without a network round-trip.
+
+ Every request to a protected endpoint on any rokojori service (Roject, tunnel, etc.) + must include the access token as a Bearer header: +
+// General pattern
+GET https://roject.rokojori.com/api/projects
+Authorization: Bearer <accessToken>
+Content-Type: application/json
+
+// If the token is valid: HTTP 200 { ... }
+// If the token is expired: HTTP 401 { "error": "Not authenticated" }
+// → refresh and retry (see section 3)
+ Store both tokens together after login and after every successful refresh:
+{
+ "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
+ "refreshToken": "a3f8c2d1-4b2e-..."
+}
+ + Persist to a local file so the session survives restarts. + The access token expires in 1 hour; the refresh token lasts 30 days. + On startup, load the saved tokens and use them directly without verifying the + access token locally — if it is expired, the first API call will return 401, + and the refresh flow will handle it. +
+## auth_store.gd
+
+const TOKEN_PATH = "user://tokens.json"
+
+func save_tokens(access_token: String, refresh_token: String) -> void:
+ var data = { "accessToken": access_token, "refreshToken": refresh_token }
+ var file = FileAccess.open(TOKEN_PATH, FileAccess.WRITE)
+ file.store_string(JSON.stringify(data))
+ file.close()
+
+func load_tokens() -> Dictionary:
+ if not FileAccess.file_exists(TOKEN_PATH):
+ return {}
+ var file = FileAccess.open(TOKEN_PATH, FileAccess.READ)
+ var text = file.get_as_text()
+ file.close()
+ var result = JSON.parse_string(text)
+ if result is Dictionary:
+ return result
+ return {}
+
+func clear_tokens() -> void:
+ if FileAccess.file_exists(TOKEN_PATH):
+ DirAccess.remove_absolute(ProjectSettings.globalize_path(TOKEN_PATH))
+
+ user:// resolves to a per-application, per-user directory that
+ persists between runs. On Windows this is typically
+ %APPDATA%/Godot/app_userdata/<project-name>/.
+
// tokens.ts
+import fs from 'fs';
+import path from 'path';
+import { app } from 'electron'; // or any fixed path for CLI tools
+
+const TOKEN_PATH = path.join( app.getPath( 'userData' ), 'tokens.json' );
+
+interface Tokens { accessToken: string; refreshToken: string; }
+
+export function saveTokens( t: Tokens ): void
+{
+ fs.writeFileSync( TOKEN_PATH, JSON.stringify( t ), 'utf-8' );
+}
+
+export function loadTokens(): Tokens | null
+{
+ try { return JSON.parse( fs.readFileSync( TOKEN_PATH, 'utf-8' ) ) as Tokens; }
+ catch { return null; }
+}
+
+export function clearTokens(): void
+{
+ try { fs.unlinkSync( TOKEN_PATH ); } catch { /* already gone */ }
+}
+ load tokens from storage
+
+if no tokens saved:
+ → show login screen
+ → POST /api/auth/login
+ → save tokens
+ → proceed
+
+if tokens are saved:
+ → proceed immediately
+ (do NOT verify the access token locally at startup —
+ it is likely expired; the first API call handles it)
+
+ Wrap all API calls in a function that catches 401, refreshes once,
+ and retries. If refresh fails, force the user back to the login screen.
+ Never retry more than once — a second 401 after refresh means the session is gone.
+
## auth_http.gd (Godot pseudocode — adapt to your HTTP library)
+
+var _tokens := {} # { accessToken, refreshToken } — loaded at startup
+
+func api_request(method: String, url: String, body: Variant = null, is_retry := false) -> Dictionary:
+ var headers = [
+ "Content-Type: application/json",
+ "Authorization: Bearer " + _tokens.get("accessToken", ""),
+ ]
+ var response = await _http_post_or_get(method, url, headers, body)
+
+ if response.status == 401 and not is_retry:
+ # Access token expired — try to refresh
+ var refreshed = await _try_refresh()
+ if refreshed:
+ return await api_request(method, url, body, true) # retry once
+ else:
+ _handle_logout() # refresh also failed — back to login
+ return { "error": "Session expired" }
+
+ return response
+
+func _try_refresh() -> bool:
+ if not _tokens.has("refreshToken"):
+ return false
+
+ var response = await _http_post(
+ "https://account.rokojori.com/api/auth/refresh",
+ ["Content-Type: application/json"],
+ { "refreshToken": _tokens["refreshToken"] }
+ )
+
+ if response.status == 200 and response.body.has("accessToken"):
+ _tokens = {
+ "accessToken": response.body["accessToken"],
+ "refreshToken": response.body["refreshToken"],
+ }
+ save_tokens(_tokens["accessToken"], _tokens["refreshToken"])
+ return true
+
+ return false # refresh token is expired or revoked
+
+func _handle_logout() -> void:
+ clear_tokens()
+ _tokens = {}
+ # emit a signal or call a function to show the login screen
+ emit_signal("session_ended")
+ // authFetch.ts
+
+const AUTH_HOST = 'https://account.rokojori.com';
+const SERVICE_URL = 'https://roject.rokojori.com'; // or whichever service
+
+let currentTokens: { accessToken: string; refreshToken: string } | null = loadTokens();
+
+async function tryRefresh(): Promise<boolean>
+{
+ if ( !currentTokens?.refreshToken ) return false;
+ try
+ {
+ const r = await fetch( `${ AUTH_HOST }/api/auth/refresh`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( { refreshToken: currentTokens.refreshToken } ),
+ } );
+ if ( !r.ok ) return false;
+ const data = await r.json() as { accessToken: string; refreshToken: string };
+ currentTokens = data;
+ saveTokens( data );
+ return true;
+ }
+ catch { return false; }
+}
+
+async function apiFetch( path: string, options: RequestInit = {}, isRetry = false ): Promise<Response>
+{
+ const res = await fetch( `${ SERVICE_URL }${ path }`,
+ {
+ ...options,
+ headers:
+ {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${ currentTokens?.accessToken ?? '' }`,
+ ...( options.headers ?? {} ),
+ },
+ } );
+
+ if ( res.status === 401 && !isRetry )
+ {
+ const ok = await tryRefresh();
+ if ( ok ) return apiFetch( path, options, true );
+ handleLogout();
+ }
+
+ return res;
+}
+
+function handleLogout(): void
+{
+ clearTokens();
+ currentTokens = null;
+ // show login screen, emit event, etc.
+}
+ // Always revoke the refresh token on the server, then clear local storage.
+// This prevents the old token from being used if the storage file is later read.
+
+async function logout(): Promise<void>
+{
+ if ( currentTokens?.refreshToken )
+ {
+ try
+ {
+ await fetch( `${ AUTH_HOST }/api/auth/logout`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( { refreshToken: currentTokens.refreshToken } ),
+ } );
+ }
+ catch { /* network error — still clear local tokens */ }
+ }
+
+ clearTokens();
+ currentTokens = null;
+}
+
+ The access token is a standard JWT (JSON Web Token). Its payload carries everything
+ your client needs to know about the user — no round-trip to
+ /api/auth/me required during normal operation.
+
// Decoded JWT payload
+{
+ "userId": "550e8400-e29b-41d4-a716-446655440000",
+ "email": "user@example.com",
+ "roles": ["user"],
+ "products": ["roject-pro"],
+ "settings": { "theme": "dark", "language": "en" },
+ "iat": 1720000000, // issued at (Unix timestamp)
+ "exp": 1720003600 // expires at (iat + 3600 seconds = 1 hour)
+}
+
+ A JWT is three base64url-encoded segments separated by dots:
+ header.payload.signature. The payload can be decoded without
+ the secret — only verification requires the secret.
+
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
+.eyJ1c2VySWQiOiI1NTBlODQwMC4uLiJ9 ← payload (base64url → JSON)
+.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← signature
+ ## jwt_decode.gd
+
+static func decode_payload(token: String) -> Dictionary:
+ var parts = token.split(".")
+ if parts.size() != 3:
+ return {}
+
+ # Base64url → base64 → bytes → UTF-8 string → JSON
+ var b64 = parts[1].replace("-", "+").replace("_", "/")
+ # Pad to a multiple of 4
+ while b64.length() % 4 != 0:
+ b64 += "="
+
+ var bytes = Marshalls.base64_to_raw(b64)
+ var text = bytes.get_string_from_utf8()
+ var result = JSON.parse_string(text)
+
+ if result is Dictionary:
+ return result
+ return {}
+
+## Usage:
+# var payload = JwtDecode.decode_payload(access_token)
+# var user_id = payload.get("userId", "")
+# var roles = payload.get("roles", [])
+# var products = payload.get("products", [])
+ + You can check whether the access token has already expired locally before making + a request. This avoids an unnecessary round-trip when you know the token is stale. + However, do not treat a non-expired + token as proof of validity — the server is the authority. +
+## Godot
+func is_access_token_expired(token: String) -> bool:
+ var payload = JwtDecode.decode_payload(token)
+ if not payload.has("exp"):
+ return true
+ return Time.get_unix_time_from_system() >= float(payload["exp"])
+
+## Node.js
+function isExpired( token: string ): boolean
+{
+ try
+ {
+ const payload = JSON.parse( Buffer.from( token.split( '.' )[1], 'base64url' ).toString( 'utf-8' ) );
+ return Date.now() / 1000 >= payload.exp;
+ }
+ catch { return true; }
+}
+
+ Optional optimisation: if you detect the token is expired before a call, run
+ tryRefresh() proactively so the first request goes out with a
+ fresh token rather than taking the 401 round-trip.
+
+ Put this in a singleton autoload (Project → Project Settings → Autoload)
+ named AuthManager. All other scripts call it instead of making HTTP
+ requests directly.
+
## AuthManager.gd
+extends Node
+
+signal session_ended
+signal login_succeeded
+
+const AUTH_HOST = "https://account.rokojori.com"
+const TOKEN_PATH = "user://tokens.json"
+
+var _access_token := ""
+var _refresh_token := ""
+
+# ── Startup ────────────────────────────────────────────────────────────
+
+func _ready() -> void:
+ _load_tokens()
+ # Do NOT verify the token here. Let the first real request handle a 401.
+
+func is_logged_in() -> bool:
+ return _refresh_token != ""
+
+# ── Login / Logout ─────────────────────────────────────────────────────
+
+func login(email: String, password: String) -> Dictionary:
+ var response = await _post(AUTH_HOST + "/api/auth/login",
+ { "email": email, "password": password })
+
+ if response.status == 200:
+ _save_new_tokens(response.body)
+ emit_signal("login_succeeded")
+ return { "ok": true }
+
+ return { "ok": false, "error": response.body.get("error", "Login failed") }
+
+func logout() -> void:
+ if _refresh_token != "":
+ await _post(AUTH_HOST + "/api/auth/logout", { "refreshToken": _refresh_token })
+ _clear_tokens()
+ emit_signal("session_ended")
+
+# ── Authenticated request (use this for all API calls) ─────────────────
+
+func request(method: String, url: String, body: Variant = null, is_retry := false) -> Dictionary:
+ var response = await _http(method, url, body, _access_token)
+
+ if response.status == 401 and not is_retry:
+ var refreshed = await _try_refresh()
+ if refreshed:
+ return await request(method, url, body, true)
+ _clear_tokens()
+ emit_signal("session_ended")
+ return { "status": 401, "body": { "error": "Session expired" } }
+
+ return response
+
+# ── Refresh ────────────────────────────────────────────────────────────
+
+func _try_refresh() -> bool:
+ if _refresh_token == "":
+ return false
+
+ var response = await _post(AUTH_HOST + "/api/auth/refresh",
+ { "refreshToken": _refresh_token })
+
+ if response.status == 200 and response.body.has("accessToken"):
+ _save_new_tokens(response.body)
+ return true
+
+ return false
+
+# ── Token storage ──────────────────────────────────────────────────────
+
+func _save_new_tokens(body: Dictionary) -> void:
+ _access_token = body.get("accessToken", "")
+ _refresh_token = body.get("refreshToken", "")
+ var file = FileAccess.open(TOKEN_PATH, FileAccess.WRITE)
+ file.store_string(JSON.stringify({
+ "accessToken": _access_token,
+ "refreshToken": _refresh_token,
+ }))
+ file.close()
+
+func _load_tokens() -> void:
+ if not FileAccess.file_exists(TOKEN_PATH):
+ return
+ var file = FileAccess.open(TOKEN_PATH, FileAccess.READ)
+ var data = JSON.parse_string(file.get_as_text())
+ file.close()
+ if data is Dictionary:
+ _access_token = data.get("accessToken", "")
+ _refresh_token = data.get("refreshToken", "")
+
+func _clear_tokens() -> void:
+ _access_token = ""
+ _refresh_token = ""
+ if FileAccess.file_exists(TOKEN_PATH):
+ DirAccess.remove_absolute(
+ ProjectSettings.globalize_path(TOKEN_PATH))
+
+# ── Low-level HTTP ─────────────────────────────────────────────────────
+
+func _post(url: String, body: Dictionary) -> Dictionary:
+ return await _http("POST", url, body, "")
+
+func _http(method: String, url: String, body: Variant, bearer: String) -> Dictionary:
+ var http = HTTPRequest.new()
+ add_child(http)
+
+ var headers := ["Content-Type: application/json"]
+ if bearer != "":
+ headers.append("Authorization: Bearer " + bearer)
+
+ var method_id := HTTPClient.METHOD_GET
+ if method == "POST": method_id = HTTPClient.METHOD_POST
+ elif method == "PATCH": method_id = HTTPClient.METHOD_PATCH
+ elif method == "DELETE": method_id = HTTPClient.METHOD_DELETE
+ elif method == "PUT": method_id = HTTPClient.METHOD_PUT
+
+ var body_str := ""
+ if body != null:
+ body_str = JSON.stringify(body)
+
+ http.request(url, headers, method_id, body_str)
+ var result = await http.request_completed
+ http.queue_free()
+
+ # result = [result_code, response_code, headers, body_bytes]
+ var status : int = result[1]
+ var body_bytes : PackedByteArray = result[3]
+ var body_parsed : Variant = JSON.parse_string(body_bytes.get_string_from_utf8())
+
+ return {
+ "status": status,
+ "body": body_parsed if body_parsed is Dictionary else {},
+ }
+ ## login_screen.gd
+
+func _on_login_pressed() -> void:
+ var result = await AuthManager.login(email_field.text, password_field.text)
+ if result["ok"]:
+ get_tree().change_scene_to_file("res://scenes/main.tscn")
+ else:
+ error_label.text = result["error"]
+
+## anywhere in the game
+
+func load_player_projects() -> void:
+ var response = await AuthManager.request("GET", "https://roject.rokojori.com/api/projects")
+ if response["status"] == 200:
+ var projects = response["body"]
+ # use projects...
+ else:
+ print("Failed to load projects: ", response["body"].get("error", "unknown"))
+ + While developing, print the HTTP status and URL of every call. + This makes the retry pattern visible and confirms refresh is working. +
+## In _http() — add before the return statement:
+print("[auth] %s %s → %d" % [method, url, status])
+
+## Expected output during a transparent refresh:
+# [auth] GET https://roject.rokojori.com/api/projects → 401
+# [auth] POST https://account.rokojori.com/api/auth/refresh → 200
+# [auth] GET https://roject.rokojori.com/api/projects → 200 ← retry succeeded
+ ## Godot — print decoded payload and expiry time
+func debug_token(token: String) -> void:
+ var payload = JwtDecode.decode_payload(token)
+ var exp = payload.get("exp", 0)
+ var expires_at = Time.get_datetime_string_from_unix_time(int(exp))
+ var now = Time.get_unix_time_from_system()
+ print("[auth] userId: ", payload.get("userId", "?"))
+ print("[auth] roles: ", payload.get("roles", []))
+ print("[auth] expires: ", expires_at)
+ print("[auth] expired: ", now >= exp)
+ # From a terminal — paste in the refreshToken from your storage file
+curl -s -X POST https://account.rokojori.com/api/auth/refresh \
+ -H "Content-Type: application/json" \
+ -d '{"refreshToken":"your-uuid-here"}' | jq
+
+# Expected on success:
+# { "accessToken": "eyJ...", "refreshToken": "new-uuid" }
+
+# Expected on failure (token expired or already used):
+# { "error": "Invalid or expired refresh token" }
+ ACCESS_TOKEN_TTL on the server is set to '10s'
+ (development artifact). The server admin must change it to '1h'.
+ See the Implementation Guide, section 1.
+ _access_token is updated from _save_new_tokens()
+ before the retry call reads it.
+ _clear_tokens() on a timeout.
+ POST /api/auth/login
+ body: { email, password }
+ response: { accessToken, refreshToken }
+ errors: 401 invalid credentials, 429 rate limited
+
+POST /api/auth/refresh
+ body: { refreshToken }
+ response: { accessToken, refreshToken } ← old refreshToken is now invalid
+ errors: 401 token expired or not found
+
+POST /api/auth/logout
+ body: { refreshToken }
+ response: { ok: true }
+ errors: always 200
+
+GET /api/auth/me
+ header: Authorization: Bearer <accessToken>
+ response: { id, email, roles, products, settings }
+ errors: 401 not authenticated
+ 1. Attach Authorization: Bearer <accessToken> to request
+2. Send request
+3. If response is 401 and not already a retry:
+ a. POST /api/auth/refresh with current refreshToken
+ b. If 200: save new tokens, retry original request once
+ c. If not 200: clear tokens, show login screen
+4. If response is 401 on retry: clear tokens, show login screen
+ accessToken: 1 hour — short-lived JWT; verify with jwt.decode() locally
+refreshToken: 30 days — opaque UUID; single-use, rotated on every refresh
+
+ Implementation Guide — server-side
+ Express middleware, Electron integration, and the canonical
+ auth.ts file. Read this first for the mental model and for
+ understanding what the server does with your token on the receiving end.
+