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. No token → next(), req.auth is undefined.
  3. Token present and valid → req.auth set → next().
  4. Token expired and refreshToken cookie present → POST to AUTH_INTERNAL_HOST/api/auth/refresh.
  5. Refresh succeeds → new cookies written on response, req.auth set from new access token → next().
  6. Refresh fails (or no refresh token) → next(), req.auth remains undefined.
  7. requireAuth gate: req.auth undefined → 401 JSON.
  8. 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.

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

Fix rokojori-auth (the auth service itself)

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

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. 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.
  3. Remove the will-redirect intercept in electron/main.ts once the redirect branch is removed from jwtMiddleware.

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

Fix styles server

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

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.