commit e2b37b882043bf2cef78e7ece3d80cad02f1cb2e Author: Rokojori Date: Fri Jul 17 09:38:05 2026 +0200 Initial Commit diff --git a/source/browser/apiFetch.ts b/source/browser/apiFetch.ts new file mode 100644 index 0000000..4081fbd --- /dev/null +++ b/source/browser/apiFetch.ts @@ -0,0 +1,54 @@ +// Browser SPA auth helper. +// +// The server's jwtMiddleware handles transparent token refresh for cookie-based clients, +// so the browser rarely needs to act on a 401. When it does arrive it means both tokens +// are dead (refresh token expired or revoked) and the user must log in again. +// +// Usage: +// import { apiFetch, setAuthRedirectUrl } from 'rokojori-auth-connector/browser/apiFetch'; +// +// // Optional — override the login URL if your app is on a different subdomain +// setAuthRedirectUrl('https://account.rokojori.com/login.html'); +// +// const res = await apiFetch('/api/projects'); +// const data = await res.json(); + +const AUTH_HOST = typeof window !== 'undefined' + ? ( ( window as unknown as Record ).AUTH_HOST as string | undefined ) + : undefined; + +let loginUrl = ( AUTH_HOST ?? 'https://account.rokojori.com' ) + '/login.html'; + +export function setAuthRedirectUrl( url: string ): void +{ + loginUrl = url; +} + +export async function apiFetch( input: RequestInfo | URL, init?: RequestInit ): Promise +{ + const res = await fetch( input, init ); + + if ( res.status === 401 ) + { + const redirect = encodeURIComponent( window.location.href ); + window.location.href = `${ loginUrl }?redirect=${ redirect }`; + } + + return res; +} + +// Decode the JWT access token payload without verifying the signature. +// Use this to read userId, roles, and products from the token the server set. +// The token is in the accessToken cookie — readable only if it is NOT HttpOnly, +// which it is by default. In that case call GET /api/auth/me instead. +export function decodeTokenPayload( token: string ): Record | null +{ + try + { + const parts = token.split( '.' ); + if ( parts.length !== 3 ) return null; + const json = atob( parts[1].replace( /-/g, '+' ).replace( /_/g, '/' ) ); + return JSON.parse( json ) as Record; + } + catch { return null; } +} diff --git a/source/server/auth.ts b/source/server/auth.ts new file mode 100644 index 0000000..c12d8c2 --- /dev/null +++ b/source/server/auth.ts @@ -0,0 +1,235 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import type { AuthPayload, AccessRule } from '../shared/types'; + +export type { AuthPayload, AccessRule }; + +// Extend Express Request so TypeScript knows about req.auth in every route file. +declare global +{ + namespace Express + { + interface Request { auth?: AuthPayload; } + } +} + +// ── Config (all from environment) ────────────────────────────────────────────── + +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'; + +// ── Internal 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 +{ + 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 ) + { + console.log( '[auth] tryRefresh error body:', await r.text() ); + return null; + } + + const data = await r.json() as Partial; + if ( !data.accessToken || !data.refreshToken ) + { + console.log( '[auth] tryRefresh: missing tokens in response' ); + 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 ────────────────────────────────────────────────────────────── +// +// Register globally before all routes and express.static. +// Sets req.auth when a valid (or silently refreshed) token is present. +// Never redirects — redirection is the responsibility of requireAuth / requireAccess. +// +// Requires in index.ts: +// app.set('trust proxy', 1) +// app.use(cookieParser()) +// app.use(jwtMiddleware) + +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 ) + { + if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } + } + + // Access token expired — attempt silent refresh via the refresh token cookie. + // Bearer-only clients (Electron) handle their own refresh in the Electron main process + // via the 401-retry pattern; they do not send a refreshToken cookie. + console.log( '[auth] expired token on:', req.method, req.path ); + + const refreshToken = req.cookies?.refreshToken as string | undefined; + if ( !refreshToken ) + { + console.log( '[auth] no refreshToken cookie — cannot refresh' ); + next(); + return; + } + + tryRefresh( refreshToken ).then( result => + { + if ( !result ) + { + console.log( '[auth] refresh failed for:', req.method, req.path ); + next(); + return; + } + + 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 + { + console.log( '[auth] unexpected: new access token failed verification' ); + } + + next(); + } ).catch( () => next() ); +} + +// ── requireAuth ──────────────────────────────────────────────────────────────── +// +// Guards API routes. Always returns 401 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 HTML pages. Redirects to login when not authenticated. +// Never use 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(); +} + +// ── requireAccess ────────────────────────────────────────────────────────────── +// +// Optional role/product gate. Use after requireAuth on routes that need +// more than "any authenticated user". +// +// Rules are OR-combined: access is granted if any rule matches. +// Within a rule, role and product are AND-combined. +// superadmin always passes regardless of rules. +// +// Example: +// const RULES: AccessRule[] = [ +// { role: 'admin' }, +// { role: 'user', product: 'pro' }, +// ]; +// app.get('/dashboard', jwtMiddleware, requireAccess(RULES), handler); + +export function requireAccess( rules: AccessRule[] ) +{ + return ( req: Request, res: Response, next: NextFunction ): void => + { + const auth = req.auth; + + if ( !auth ) + { + if ( req.accepts( 'html' ) ) + { + const here = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl ); + res.redirect( `${ AUTH_HOST }/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' } ); + } + }; +} diff --git a/source/shared/types.ts b/source/shared/types.ts new file mode 100644 index 0000000..36cbee2 --- /dev/null +++ b/source/shared/types.ts @@ -0,0 +1,21 @@ +// Decoded JWT payload — shape issued by rokojori-auth and verified by every service. +export interface AuthPayload +{ + userId: string; + email: string; + roles: string[]; + products: string[]; + settings: Record; +} + +// Stored token pair — returned by /api/auth/login and /api/auth/refresh. +export interface Tokens +{ + accessToken: string; + refreshToken: string; +} + +// One entry in a service's access-rule list. +// role and product are AND-combined within a rule; rules are OR-combined across the list. +// superadmin bypasses all rules automatically. +export type AccessRule = { role: string; product?: string }; diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js new file mode 100644 index 0000000..0e05cfb --- /dev/null +++ b/workspace/_assets_/nav-data.js @@ -0,0 +1,5 @@ +var NAV_DATA = { + title: 'Workspace', + path: 'index.html', + children: [] +}; diff --git a/workspace/_assets_/nav.css b/workspace/_assets_/nav.css new file mode 100644 index 0000000..2a807b1 --- /dev/null +++ b/workspace/_assets_/nav.css @@ -0,0 +1,92 @@ +.wsnav +{ + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #1a1d27; + border-bottom: 1px solid #2a2d3a; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 0.82rem; +} + +.wsnav-inner +{ + width: 65em; + margin: 0 auto; + padding: 0.45rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wsnav a +{ + color: #7c8cff; + text-decoration: none; +} + +.wsnav a:hover +{ + text-decoration: underline; +} + +/* ── Breadcrumb ── */ + +.wsnav-crumb +{ + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.wsnav-sep +{ + color: #4a4e6a; + font-size: 0.9em; +} + +.wsnav-current +{ + color: #e2e4ed; + font-weight: 600; +} + +/* ── Siblings ── */ + +.wsnav-siblings +{ + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + color: #7b7f96; + font-size: 0.88em; +} + +.wsnav-sib-current +{ + color: #e2e4ed; + font-weight: 600; +} + +.wsnav-sib-sep +{ + color: #4a4e6a; +} + +/* ── Children ── */ + +.wsnav-children +{ + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.88em; + padding-top: 0.1rem; + border-top: 1px solid #2a2d3a; + margin-top: 0.1rem; +} diff --git a/workspace/_assets_/nav.js b/workspace/_assets_/nav.js new file mode 100644 index 0000000..5eefc0b --- /dev/null +++ b/workspace/_assets_/nav.js @@ -0,0 +1,133 @@ +(function () +{ + function getCurrentPath() + { + var href = window.location.href.replace(/\\/g, '/'); + var marker = 'workspace/'; + var idx = href.lastIndexOf(marker); + if (idx === -1) return 'index.html'; + var after = href.slice(idx + marker.length); + return after || 'index.html'; + } + + function findNode(node, targetPath, ancestors) + { + if (node.path === targetPath) return { node: node, ancestors: ancestors }; + var children = node.children || []; + for (var i = 0; i < children.length; i++) + { + var result = findNode(children[i], targetPath, ancestors.concat(node)); + if (result) return result; + } + return null; + } + + function makeLink(node, label) + { + var a = document.createElement('a'); + a.href = NAV_ROOT + node.path; + a.textContent = label || node.title; + return a; + } + + function render() + { + var currentPath = getCurrentPath(); + var found = findNode(NAV_DATA, currentPath, []); + + var nav = document.createElement('nav'); + nav.className = 'wsnav'; + + var inner = document.createElement('div'); + inner.className = 'wsnav-inner'; + nav.appendChild(inner); + + // ── Breadcrumb ─────────────────────────────────────────────────── + var crumb = document.createElement('div'); + crumb.className = 'wsnav-crumb'; + + var ancestors = found ? found.ancestors : []; + var currentNode = found ? found.node : null; + + for (var i = 0; i < ancestors.length; i++) + { + var a = makeLink(ancestors[i]); + crumb.appendChild(a); + var sep = document.createElement('span'); + sep.className = 'wsnav-sep'; + sep.textContent = '›'; + crumb.appendChild(sep); + } + + var current = document.createElement('span'); + current.className = 'wsnav-current'; + current.textContent = currentNode ? currentNode.title : currentPath; + crumb.appendChild(current); + + inner.appendChild(crumb); + + // ── Siblings ───────────────────────────────────────────────────── + var parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null; + var siblings = parent ? (parent.children || []) : []; + + if (siblings.length > 1) + { + var sibRow = document.createElement('div'); + sibRow.className = 'wsnav-siblings'; + + for (var j = 0; j < siblings.length; j++) + { + var sib = siblings[j]; + if (sib.path === currentPath) + { + var mark = document.createElement('span'); + mark.className = 'wsnav-sib-current'; + mark.textContent = sib.title; + sibRow.appendChild(mark); + } + else + { + sibRow.appendChild(makeLink(sib)); + } + + if (j < siblings.length - 1) + { + var div = document.createElement('span'); + div.className = 'wsnav-sib-sep'; + div.textContent = '·'; + sibRow.appendChild(div); + } + } + + inner.appendChild(sibRow); + } + + // ── Children ───────────────────────────────────────────────────── + var children = currentNode ? (currentNode.children || []) : []; + + if (children.length > 0) + { + var childRow = document.createElement('div'); + childRow.className = 'wsnav-children'; + + for (var k = 0; k < children.length; k++) + { + childRow.appendChild(makeLink(children[k])); + } + + inner.appendChild(childRow); + } + + document.body.insertBefore(nav, document.body.firstChild); + document.body.style.paddingTop = (nav.offsetHeight + 8) + 'px'; + } + + if (document.readyState === 'loading') + { + document.addEventListener('DOMContentLoaded', render); + } + else + { + render(); + } +})(); diff --git a/workspace/_assets_/styles.css b/workspace/_assets_/styles.css new file mode 100644 index 0000000..44801cf --- /dev/null +++ b/workspace/_assets_/styles.css @@ -0,0 +1,200 @@ +*, *::before, *::after +{ + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root +{ + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e2e4ed; + --muted: #7b7f96; + --accent: #7c8cff; + --tag-bg: #1e2235; + --tag-text: #9ba4c7; + --font-size: 20px; +} + +html +{ + font-size: calc( var( --font-size ) ); + -webkit-font-smoothing: antialiased; +} + +body +{ + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.7; + padding: calc( var( --font-size ) * 3 ) calc( var( --font-size ) * 1.5 ) calc( var( --font-size ) * 6 ); +} + +.page +{ + max-width: 50em; + margin: 0 auto; +} + +header +{ + margin-bottom: calc( var( --font-size ) * 3 ); + padding-bottom: calc( var( --font-size ) * 1.5 ); + border-bottom: 1px solid var(--border); +} + +header .date +{ + font-size: calc( var( --font-size ) * 0.8 ); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +header h1 +{ + font-size: calc( var( --font-size ) * 1.9 ); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +header .subtitle +{ + margin-top: calc( var( --font-size ) * 0.5 ); + color: var(--muted); + font-size: calc( var( --font-size ) * 0.95 ); +} + +section +{ + margin-bottom: calc( var( --font-size ) * 2.5 ); +} + +section h2 +{ + font-size: calc( var( --font-size ) * 1.25 ); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: calc( var( --font-size ) * 1 ); +} + +.card +{ + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: calc( var( --font-size ) * 1.25 ) calc( var( --font-size ) * 1.5 ); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +.card h3 +{ + font-size: calc( var( --font-size ) * 1 ); + font-weight: 600; + margin-bottom: calc( var( --font-size ) * 0.35 ); + color: var(--text); +} + +.card p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); + line-height: 1.6; +} + +.tags +{ + display: flex; + flex-wrap: wrap; + gap: calc( var( --font-size ) * 0.4 ); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +.tag +{ + background: var(--tag-bg); + color: var(--tag-text); + font-size: calc( var( --font-size ) * 0.75 ); + padding: calc( var( --font-size ) * 0.2 ) calc( var( --font-size ) * 0.6 ); + border-radius: 4px; + font-family: ui-monospace, "Cascadia Code", monospace; +} + +.decision +{ + border-left: 3px solid var(--accent); + padding-left: calc( var( --font-size ) * 1 ); + margin-bottom: calc( var( --font-size ) * 2 ); +} + +.decision p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); +} + +.decision strong +{ + color: var(--text); + display: block; + margin-bottom: calc( var( --font-size ) * 0.2 ); +} + +footer +{ + margin-top: calc( var( --font-size ) * 4 ); + padding-top: calc( var( --font-size ) * 1.5 ); + border-top: 1px solid var(--border); + font-size: calc( var( --font-size ) * 0.8 ); + color: var(--muted); +} + +a +{ + color: var(--accent); + text-decoration: none; +} + +a:hover +{ + text-decoration: underline; +} + +code +{ + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: 0.85em; + color: var(--accent); + background: var(--tag-bg); + padding: 0.1em 0.35em; + border-radius: 3px; +} + +pre +{ + background: #0a0c14; + border: 1px solid var(--border); + border-radius: 6px; + padding: calc( var( --font-size ) * 1 ) calc( var( --font-size ) * 1.25 ); + overflow-x: auto; + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: calc( var( --font-size ) * 0.82 ); + line-height: 1.6; + color: var(--text); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +pre code +{ + background: none; + color: inherit; + padding: 0; + font-size: inherit; +} diff --git a/workspace/index.html b/workspace/index.html new file mode 100644 index 0000000..bf82bb1 --- /dev/null +++ b/workspace/index.html @@ -0,0 +1,368 @@ + + + + + + rokojori-auth-connector + + + + +
+ +
+

Submodule

+

rokojori-auth-connector

+

+ Shared auth integration for all rokojori services. Add as a git submodule. + Provides server-side Express middleware, shared TypeScript types, and a + browser SPA helper. One source of truth — one fix reaches every app. +

+
+ + +
+

What this provides

+ +
+

File structure

+
rokojori-auth-connector/
+  source/
+    shared/
+      types.ts          — AuthPayload, Tokens, AccessRule
+    server/
+      auth.ts           — jwtMiddleware, requireAuth, requireAuthPage, requireAccess
+    browser/
+      apiFetch.ts       — SPA fetch wrapper; redirects to login on 401
+  workspace/
+    index.html          — this document
+
+ +
+

source/shared/types.ts

+

+ The three shared types used across every layer. Import from here rather than + re-declaring them in each service. +

+
+ AuthPayload + Tokens + AccessRule +
+
+ +
+

source/server/auth.ts

+

+ Express middleware for any Node.js service. Handles JWT verification, + transparent cookie-based token refresh, and route protection. +

+
+ jwtMiddleware + requireAuth + requireAuthPage + requireAccess +
+
+ +
+

source/browser/apiFetch.ts

+

+ Minimal browser fetch wrapper. The server handles transparent token refresh, + so the only browser-side concern is reacting to a 401 (both tokens dead) by + redirecting to the login page. +

+
+ apiFetch + setAuthRedirectUrl + decodeTokenPayload +
+
+
+ + +
+

1 — Add the submodule

+ +
+

In your service repo

+
git submodule add https://community.rokojori.com/Rokojori/rokojori-auth-connector.git shared/auth-connector
+git submodule update --init
+

+ This creates shared/auth-connector/ inside your service repo. + Import directly from that path — no build step, no install step. +

+
+ +
+

When the submodule is updated

+
# Inside your service repo — pull the latest connector and commit the new SHA
+git submodule update --remote shared/auth-connector
+git add shared/auth-connector
+git commit -m "update auth-connector"
+

+ Each service repo pins a specific commit of the connector. Updates are + explicit and deliberate — a change in the connector does not silently affect + services that have not pulled it yet. +

+
+ +
+

After cloning a repo that uses this submodule

+
git clone --recurse-submodules <repo-url>
+
+# Or if you already cloned without --recurse-submodules:
+git submodule update --init
+
+
+ + +
+

2 — TypeScript path alias (optional but recommended)

+ +
+

tsconfig.json

+

+ Add a path alias so imports read cleanly instead of using relative + ../../shared/auth-connector/source/... paths. +

+
{
+  "compilerOptions": {
+    "paths": {
+      "auth-connector/*": ["./shared/auth-connector/source/*"]
+    }
+  }
+}
+
+ +
+

With the alias

+
import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth';
+import type { AuthPayload }           from 'auth-connector/shared/types';
+
+ +
+

Without the alias (direct relative path)

+
import { jwtMiddleware, requireAuth } from '../../shared/auth-connector/source/server/auth';
+import type { AuthPayload }           from '../../shared/auth-connector/source/shared/types';
+
+
+ + +
+

3 — Environment variables

+ +
+

+ Add these to your service's .env (and the production + .env on the server). All are read at runtime — no rebuild needed + when they change. +

+
# Required — must match the JWT_SECRET in rokojori-auth exactly
+JWT_SECRET=...
+
+# Public URL of the auth service
+# Used when redirecting unauthenticated browsers to the login page
+AUTH_HOST=https://account.rokojori.com
+
+# Internal URL for server-to-server token refresh calls
+# Set to the local port in production to bypass nginx TLS overhead
+# Leave unset in dev — falls back to AUTH_HOST
+AUTH_INTERNAL_HOST=http://localhost:3001
+
+# Cookie domain — must match COOKIE_DOMAIN in rokojori-auth
+COOKIE_DOMAIN=.rokojori.com
+
+
+ + +
+

4 — Wire into index.ts

+ +
+

Minimal setup

+
import 'dotenv/config';
+import express from 'express';
+import cookieParser from 'cookie-parser';
+import { jwtMiddleware, requireAuth, requireAuthPage } from 'auth-connector/server/auth';
+
+const app = express();
+
+app.set( 'trust proxy', 1 );   // required — real client IPs, secure cookie flag
+app.use( express.json() );
+app.use( cookieParser() );
+app.use( jwtMiddleware );       // runs on every request before routes and static files
+
+app.use( express.static( ... ) );
+
+// API route — returns 401 JSON if not authenticated
+app.get( '/api/me', requireAuth, ( req, res ) =>
+{
+  res.json( { userId: req.auth!.userId } );
+} );
+
+// Page route — redirects to login if not authenticated
+app.get( '/dashboard', requireAuthPage, ( req, res ) =>
+{
+  res.sendFile( ... );
+} );
+
+ +
+

With role/product gating

+
import { jwtMiddleware, requireAuth, requireAccess } from 'auth-connector/server/auth';
+import type { AccessRule } from 'auth-connector/shared/types';
+
+const MY_RULES: AccessRule[] = [
+  { role: 'admin' },
+  { role: 'user', product: 'my-product' },
+];
+
+// requireAccess includes its own 401/403 handling — no need to add requireAuth before it
+app.get( '/api/premium', jwtMiddleware, requireAccess( MY_RULES ), handler );
+
+
+ + +
+

5 — Browser SPA

+ +
+

Drop-in fetch replacement

+

+ The server's jwtMiddleware handles transparent token refresh for + cookie clients — the browser receives rotated cookies on every refreshed response + without doing anything. The only case the browser needs to act on is a + 401, which means both tokens are dead and the user must log in again. +

+
import { apiFetch } from 'auth-connector/browser/apiFetch';
+
+// Use apiFetch exactly like fetch — it redirects to login on 401
+const res  = await apiFetch( '/api/projects' );
+const data = await res.json();
+
+ +
+

Override the login URL

+

+ Call this once at app startup if your login page is not at the default + https://account.rokojori.com/login.html. +

+
import { setAuthRedirectUrl } from 'auth-connector/browser/apiFetch';
+
+setAuthRedirectUrl( 'https://account.rokojori.com/login.html' );
+
+
+ + +
+

6 — What jwtMiddleware does, step by step

+ +
+

Understanding this flow is essential for debugging. Watch it live with:

+
journalctl -u <your-service> -f | grep '\[auth\]'
+
+ +
+

Normal request (valid token)

+
1. Extract token from accessToken cookie or Authorization: Bearer header
+2. jwt.verify() succeeds
+3. req.auth = decoded payload
+4. next() — route handler runs
+
+ +
+

Expired access token, refresh token present (cookie client)

+
[auth] expired token on: GET /api/projects
+[auth] tryRefresh → http://localhost:3001/api/auth/refresh
+[auth] tryRefresh status: 200
+[auth] tryRefresh: succeeded
+
+1. jwt.verify() throws TokenExpiredError
+2. Read refreshToken cookie
+3. POST AUTH_INTERNAL_HOST/api/auth/refresh with the refresh token
+4. Write new accessToken + refreshToken cookies onto the response
+5. req.auth = decoded new access token
+6. next() — route handler runs, client receives rotated cookies transparently
+
+ +
+

Expired access token, refresh token also expired

+
[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
+
+1. jwt.verify() throws TokenExpiredError
+2. POST to refresh endpoint → 401
+3. next() — req.auth remains undefined
+4. requireAuth returns 401 JSON
+   requireAccess returns 401 JSON or redirects to login (page routes)
+
+ +
+

Electron / Bearer-only client

+
1. Token extracted from Authorization: Bearer header
+2. If expired: no refreshToken cookie → next() with req.auth undefined → 401
+3. The Electron main process sees the 401, calls tryRefreshTokens(), retries once
+4. This is intentional — Bearer clients manage their own refresh cycle
+

+ See the rokojori-auth-connector non-browser guide for the Electron + apiFetch pattern. +

+
+
+ + +
+

7 — Checklist for a new service

+ +
+
    +
  1. + git submodule add — add the connector at + shared/auth-connector/ +
  2. +
  3. + Add path alias to tsconfig.json +
  4. +
  5. + Add JWT_SECRET, AUTH_HOST, + AUTH_INTERNAL_HOST, COOKIE_DOMAIN to + .env and the server .env +
  6. +
  7. + In index.ts: app.set('trust proxy', 1), + app.use(cookieParser()), + app.use(jwtMiddleware) — in that order, before routes +
  8. +
  9. + Use requireAuth on API routes, + requireAuthPage on page routes +
  10. +
  11. + Use requireAccess(rules) where role/product gating is needed +
  12. +
  13. + In browser TypeScript: replace fetch with apiFetch + from auth-connector/browser/apiFetch +
  14. +
  15. + Deploy and verify with + journalctl -u <service> -f | grep '\[auth\]' +
  16. +
+
+
+ +
+ rokojori-auth-connector +
+ +
+ + + + +