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 @@ + + + + + + Auth Implementation Guide — rokojori + + + + +
+ +
+

Guide

+

Auth Implementation 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. +

+
+ + +
+

0 — Mental model

+ +
+

Two tokens, two jobs

+

+ Every authenticated session carries two tokens. Understanding their roles + is required before touching any auth code. +

+
    +
  • + accessToken — a signed JWT containing + 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. +
  • +
  • + refreshToken — an opaque UUID stored in + 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. +
  • +
+
+ +
+

Token storage by client type

+
    +
  • + Browser (cookie) — both tokens are set as + HttpOnly; Secure; SameSite=Lax cookies on .rokojori.com. + Every request from any subdomain automatically carries them. The browser manages them. +
  • +
  • + Electron / native — tokens are returned in + the login response body and stored locally (e.g. userData/tokens.json). + Must be attached to every request as + Authorization: Bearer <accessToken>. +
  • +
+
+ +
+

The rule: never redirect to refresh

+

+ 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. +

+
+ +
+

The refresh endpoint

+

+ 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. +

+
+
+ + +
+

1 — Current state and known bugs

+ +
+

ACCESS_TOKEN_TTL is set to 10 seconds in production

+

+ 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'. +

+
+ +
+

Four different middleware implementations

+

+ 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. +
  • +
+
+ +
+

Three different Electron refresh implementations

+
    +
  • + 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. +
  • +
+
+ +
+

req.auth vs req.user

+

+ 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). +

+
+
+ + +
+

2 — Canonical server-side middleware

+ +
+

File location

+

+ 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. +

+
+ +
+

Environment variables required

+
# 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
+
+ +
+

The canonical auth.ts

+
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();
+}
+
+ +
+

Wiring in index.ts

+

+ 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( ... );
+} );
+
+ +
+

What happens step by step

+
    +
  1. Request arrives. jwtMiddleware reads accessToken cookie + or Authorization: Bearer header.
  2. +
  3. No token → next(), req.auth is undefined.
  4. +
  5. Token present and valid → req.auth set → next().
  6. +
  7. Token expired and refreshToken cookie present → + POST to AUTH_INTERNAL_HOST/api/auth/refresh.
  8. +
  9. Refresh succeeds → new cookies written on response, req.auth set + from new access token → next().
  10. +
  11. Refresh fails (or no refresh token) → next(), + req.auth remains undefined.
  12. +
  13. requireAuth gate: req.auth undefined → 401 JSON.
  14. +
  15. requireAuthPage gate: req.auth undefined → redirect to + AUTH_HOST/login.html?redirect=<url>.
  16. +
+

+ At no point is a redirect issued for a request that has a valid refresh token. +

+
+
+ + +
+

3 — Per-service access rules (requireAccess)

+ +
+

When to use

+

+ 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. +

+
+ +
+

requireAccess.ts

+
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' } );
+    }
+  };
+}
+
+ +
+

Example — styles service

+
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( ... );
+} );
+
+
+ + +
+

4 — Electron main process auth

+ +
+

Storage

+

+ 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 */ } }
+
+ +
+

Login

+

+ 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 ) }; }
+} );
+
+ +
+

apiFetch — the canonical request wrapper

+

+ 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();
+}
+
+ +
+

Header injection for embedded webview (Roject Electron only)

+

+ 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. +

+
+ +
+

Startup — check saved tokens

+

+ 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. +

+
+
+ + +
+

5 — Debugging

+ +
+

Console log prefix

+

+ 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
+
+ +
+

Common failure modes

+
    +
  • + tryRefresh always returns null + — check 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. +
  • +
  • + 401 every 10 seconds + — ACCESS_TOKEN_TTL in rokojori-auth is still set to '10s'. + Change to '1h'. +
  • +
  • + Refresh succeeds but user still gets 401 + — the route is using the old 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. +
  • +
  • + req.auth is undefined even with a valid 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 ). +
  • +
  • + Electron: Unauthorized after long idle + — 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 not sent on API requests + — check COOKIE_DOMAIN in the service's .env. It must be + .rokojori.com (leading dot) to cover all subdomains. Verify with + DevTools → Application → Cookies. +
  • +
  • + JWT_SECRET mismatch + — if 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. +
  • +
+
+ +
+

Decode a JWT without verifying (for inspection)

+
// 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() );
+
+ +
+

Test refresh manually

+
# 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
+
+
+ + +
+

6 — Per-service migration checklist

+ +
+

When adding auth to a new service or fixing an existing one

+
    +
  1. Copy the canonical auth.ts from section 2 into + source/server/middleware/auth.ts.
  2. +
  3. Delete any existing requireAuth.ts that does not have + jwtMiddleware — it is incomplete.
  4. +
  5. In source/server/index.ts: add app.use( cookieParser() ) + and app.use( jwtMiddleware ) before all routes and static files.
  6. +
  7. Set app.set( 'trust proxy', 1 ) so secure cookies and real client IPs work.
  8. +
  9. Add JWT_SECRET, AUTH_HOST, + AUTH_INTERNAL_HOST, COOKIE_DOMAIN to .env + (and to the server .env).
  10. +
  11. Replace all uses of req.user with req.auth.
  12. +
  13. Add requireAccess.ts (section 3) if the service needs product/role gating.
  14. +
  15. Deploy and watch journalctl -u <service> -f | grep '\[auth\]' + to verify the refresh flow fires correctly.
  16. +
+
+ +
+

Fix rokojori-auth (the auth service itself)

+
    +
  1. + Change ACCESS_TOKEN_TTL in + source/server/routes/auth.ts from '10s' to + '1h'. +
  2. +
  3. + The 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. +
  4. +
  5. + The standalone 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. +
  6. +
+
+ +
+

Fix Roject server

+
    +
  1. + source/server/middleware/auth.ts already has the correct + jwtMiddleware pattern. Replace req.user with + req.auth throughout all route files. +
  2. +
  3. + Remove the non-API redirect in jwtMiddleware (the + if ( !isApiRequest( req ) ) branch). With server-side silent + refresh, page requests are also handled transparently — the redirect is no + longer needed. +
  4. +
  5. + Remove the will-redirect intercept in + electron/main.ts once the redirect branch is removed from + jwtMiddleware. +
  6. +
+
+ +
+

Fix tunnel server

+
    +
  1. + Replace 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. +
  2. +
  3. + Add cookieParser() and jwtMiddleware in + source/server/index.ts (for /api/tunnels routes). +
  4. +
  5. + The Tunnel Electron app (electron-agent/main.ts) already uses the + correct apiFetch + tryRefreshTokens pattern — no changes needed there. +
  6. +
+
+ +
+

Fix styles server

+
    +
  1. + Same as tunnel: replace requireAuth.ts with the canonical + auth.ts and add jwtMiddleware to index.ts. +
  2. +
  3. + 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). +
  4. +
+
+
+ + +
+

7 — Future: shared auth library

+ +
+

Why

+

+ 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. +

+
+ +
+

What it would export

+
// @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 };
+
+ +
+

When to do it

+

+ 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"). +

+
+
+ + +
+

Quick reference

+ +
+

Environment variables

+
# 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
+
+ +
+

Endpoints on account.rokojori.com

+
+ POST /api/auth/login + POST /api/auth/register + POST /api/auth/logout + POST /api/auth/refresh — body: { refreshToken } + GET /api/auth/me — requires valid accessToken +
+
+ +
+

Token lifetimes

+
accessToken:  1 hour   (JWT, verified locally with JWT_SECRET)
+refreshToken: 30 days  (opaque UUID, stored in rokojori-auth db)
+
+ +
+

Files to create or replace in each service

+
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.

+
+
+ + + +
+ + + + + diff --git a/workspace/non-browser-implementation-guide.html b/workspace/non-browser-implementation-guide.html new file mode 100644 index 0000000..77159e9 --- /dev/null +++ b/workspace/non-browser-implementation-guide.html @@ -0,0 +1,849 @@ + + + + + + Non-Browser Auth Guide — rokojori + + + + +
+ +
+

Guide

+

Non-Browser Auth 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. +

+
+ + +
+

0 — How non-browser clients differ

+ +
+

No cookies

+

+ 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. +

+
+ +
+

No redirects

+

+ 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. +

+
+ +
+

Manual token management

+

+ Your client is responsible for three things the browser does automatically: +

+
    +
  1. Storing both tokens after login (and after every refresh).
  2. +
  3. Attaching the access token to every authenticated request as + Authorization: Bearer <accessToken>.
  4. +
  5. Detecting a 401 response, refreshing transparently, and retrying + the original request once with the new token.
  6. +
+
+
+ + +
+

1 — Endpoint reference

+ +
+

Base URL

+
https://account.rokojori.com
+

+ All requests use Content-Type: application/json and expect + a JSON response body. +

+
+ +
+

POST /api/auth/login

+

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." }
+
+ +
+

POST /api/auth/refresh

+

+ 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
+
+ +
+

POST /api/auth/logout

+

+ 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
+
+ +
+

GET /api/auth/me

+

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. +

+
+ +
+

Calling any protected service endpoint

+

+ 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)
+
+
+ + +
+

2 — Token storage

+ +
+

What to store

+

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. +

+
+ +
+

Godot — store in user:// directory

+
## 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>/. +

+
+ +
+

Node.js / Electron — store in userData

+
// 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 */ }
+}
+
+
+ + +
+

3 — The auth flow

+ +
+

Decision tree on startup

+
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)
+
+ +
+

Wrapping every API call — the retry pattern

+

+ 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")
+
+ +
+

Node.js version of the same pattern

+
// 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.
+}
+
+ +
+

Logout flow

+
// 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;
+}
+
+
+ + +
+

4 — Reading the access token payload

+ +
+

What is in the access token

+

+ 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)
+}
+
+ +
+

JWT structure

+

+ 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
+
+ +
+

Godot — decode the payload

+
## 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", [])
+
+ +
+

Check expiry without verifying

+

+ 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. +

+
+
+ + +
+

5 — Full Godot example

+ +
+

AuthManager autoload

+

+ 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 {},
+    }
+
+ +
+

Usage from any other script

+
## 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"))
+
+
+ + +
+

6 — Debugging

+ +
+

Log every request and response

+

+ 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
+
+ +
+

Inspect token expiry

+
## 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)
+
+ +
+

Test the refresh endpoint manually

+
# 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" }
+
+ +
+

Common failure modes

+
    +
  • + 401 on every request, refresh also 401 + — the refreshToken is expired (30 days) or was already used and discarded. + Clear stored tokens and prompt for login. +
  • +
  • + 401 immediately after login + — the saved accessToken from a previous session is being sent instead of + the fresh one. Always overwrite stored tokens immediately after login. +
  • +
  • + Every request returns 401 then succeeds on retry, + forever + — 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. +
  • +
  • + Refresh returns 200 but next request still 401 + — you saved the new tokens but are still sending the old accessToken. + Make sure _access_token is updated from _save_new_tokens() + before the retry call reads it. +
  • +
  • + No response / timeout + — network issue or the service is down. Handle as a connection error separately + from auth errors. Do not call _clear_tokens() on a timeout. +
  • +
+
+
+ + +
+

Quick reference

+ +
+

Endpoint summary

+
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
+
+ +
+

Request pattern for every protected call

+
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
+
+ +
+

Token lifetimes

+
accessToken:  1 hour   — short-lived JWT; verify with jwt.decode() locally
+refreshToken: 30 days  — opaque UUID; single-use, rotated on every refresh
+
+ +
+

Related documents

+

+ 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. +

+
+
+ + + +
+ + + + +