Initial Commit

This commit is contained in:
Rokojori 2026-07-17 09:38:05 +02:00
commit e2b37b8820
8 changed files with 1108 additions and 0 deletions

View File

@ -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<string, unknown> ).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<Response>
{
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<string, unknown> | 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<string, unknown>;
}
catch { return null; }
}

235
source/server/auth.ts Normal file
View File

@ -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<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 )
{
console.log( '[auth] tryRefresh error body:', await r.text() );
return null;
}
const data = await r.json() as Partial<RefreshResult>;
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' } );
}
};
}

21
source/shared/types.ts Normal file
View File

@ -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<string, unknown>;
}
// 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 };

View File

@ -0,0 +1,5 @@
var NAV_DATA = {
title: 'Workspace',
path: 'index.html',
children: []
};

View File

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

133
workspace/_assets_/nav.js Normal file
View File

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

View File

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

368
workspace/index.html Normal file
View File

@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rokojori-auth-connector</title>
<link rel="stylesheet" href="./_assets_/styles.css">
<link rel="stylesheet" href="./_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Submodule</p>
<h1>rokojori-auth-connector</h1>
<p class="subtitle">
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.
</p>
</header>
<!-- ─── What's in here ───────────────────────────────────────── -->
<section>
<h2>What this provides</h2>
<div class="card">
<h3>File structure</h3>
<pre><code>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</code></pre>
</div>
<div class="card">
<h3>source/shared/types.ts</h3>
<p>
The three shared types used across every layer. Import from here rather than
re-declaring them in each service.
</p>
<div class="tags">
<span class="tag">AuthPayload</span>
<span class="tag">Tokens</span>
<span class="tag">AccessRule</span>
</div>
</div>
<div class="card">
<h3>source/server/auth.ts</h3>
<p>
Express middleware for any Node.js service. Handles JWT verification,
transparent cookie-based token refresh, and route protection.
</p>
<div class="tags">
<span class="tag">jwtMiddleware</span>
<span class="tag">requireAuth</span>
<span class="tag">requireAuthPage</span>
<span class="tag">requireAccess</span>
</div>
</div>
<div class="card">
<h3>source/browser/apiFetch.ts</h3>
<p>
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.
</p>
<div class="tags">
<span class="tag">apiFetch</span>
<span class="tag">setAuthRedirectUrl</span>
<span class="tag">decodeTokenPayload</span>
</div>
</div>
</section>
<!-- ─── 1. Add the submodule ─────────────────────────────────── -->
<section>
<h2>1 — Add the submodule</h2>
<div class="card">
<h3>In your service repo</h3>
<pre><code>git submodule add https://community.rokojori.com/Rokojori/rokojori-auth-connector.git shared/auth-connector
git submodule update --init</code></pre>
<p style="margin-top:0.75rem">
This creates <code>shared/auth-connector/</code> inside your service repo.
Import directly from that path — no build step, no install step.
</p>
</div>
<div class="card">
<h3>When the submodule is updated</h3>
<pre><code># 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"</code></pre>
<p style="margin-top:0.75rem">
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.
</p>
</div>
<div class="card">
<h3>After cloning a repo that uses this submodule</h3>
<pre><code>git clone --recurse-submodules &lt;repo-url&gt;
# Or if you already cloned without --recurse-submodules:
git submodule update --init</code></pre>
</div>
</section>
<!-- ─── 2. TypeScript path alias ────────────────────────────── -->
<section>
<h2>2 — TypeScript path alias (optional but recommended)</h2>
<div class="card">
<h3>tsconfig.json</h3>
<p>
Add a path alias so imports read cleanly instead of using relative
<code>../../shared/auth-connector/source/...</code> paths.
</p>
<pre><code>{
"compilerOptions": {
"paths": {
"auth-connector/*": ["./shared/auth-connector/source/*"]
}
}
}</code></pre>
</div>
<div class="card">
<h3>With the alias</h3>
<pre><code>import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth';
import type { AuthPayload } from 'auth-connector/shared/types';</code></pre>
</div>
<div class="card">
<h3>Without the alias (direct relative path)</h3>
<pre><code>import { jwtMiddleware, requireAuth } from '../../shared/auth-connector/source/server/auth';
import type { AuthPayload } from '../../shared/auth-connector/source/shared/types';</code></pre>
</div>
</section>
<!-- ─── 3. Environment variables ────────────────────────────── -->
<section>
<h2>3 — Environment variables</h2>
<div class="card">
<p>
Add these to your service's <code>.env</code> (and the production
<code>.env</code> on the server). All are read at runtime — no rebuild needed
when they change.
</p>
<pre><code># 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</code></pre>
</div>
</section>
<!-- ─── 4. Wire into index.ts ────────────────────────────────── -->
<section>
<h2>4 — Wire into index.ts</h2>
<div class="card">
<h3>Minimal setup</h3>
<pre><code>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( ... );
} );</code></pre>
</div>
<div class="card">
<h3>With role/product gating</h3>
<pre><code>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 );</code></pre>
</div>
</section>
<!-- ─── 5. Browser SPA ────────────────────────────────────────── -->
<section>
<h2>5 — Browser SPA</h2>
<div class="card">
<h3>Drop-in fetch replacement</h3>
<p>
The server's <code>jwtMiddleware</code> 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
<code>401</code>, which means both tokens are dead and the user must log in again.
</p>
<pre><code>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();</code></pre>
</div>
<div class="card">
<h3>Override the login URL</h3>
<p>
Call this once at app startup if your login page is not at the default
<code>https://account.rokojori.com/login.html</code>.
</p>
<pre><code>import { setAuthRedirectUrl } from 'auth-connector/browser/apiFetch';
setAuthRedirectUrl( 'https://account.rokojori.com/login.html' );</code></pre>
</div>
</section>
<!-- ─── 6. What jwtMiddleware does ───────────────────────────── -->
<section>
<h2>6 — What jwtMiddleware does, step by step</h2>
<div class="card">
<p>Understanding this flow is essential for debugging. Watch it live with:</p>
<pre><code>journalctl -u &lt;your-service&gt; -f | grep '\[auth\]'</code></pre>
</div>
<div class="card">
<h3>Normal request (valid token)</h3>
<pre><code>1. Extract token from accessToken cookie or Authorization: Bearer header
2. jwt.verify() succeeds
3. req.auth = decoded payload
4. next() — route handler runs</code></pre>
</div>
<div class="card">
<h3>Expired access token, refresh token present (cookie client)</h3>
<pre><code>[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</code></pre>
</div>
<div class="card">
<h3>Expired access token, refresh token also expired</h3>
<pre><code>[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)</code></pre>
</div>
<div class="card">
<h3>Electron / Bearer-only client</h3>
<pre><code>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</code></pre>
<p style="margin-top:0.75rem">
See the rokojori-auth-connector non-browser guide for the Electron
<code>apiFetch</code> pattern.
</p>
</div>
</section>
<!-- ─── 7. Checklist for a new service ────────────────────────── -->
<section>
<h2>7 — Checklist for a new service</h2>
<div class="card">
<ol style="line-height:2;font-size:0.9rem;color:var(--muted)">
<li>
<code>git submodule add</code> — add the connector at
<code>shared/auth-connector/</code>
</li>
<li>
Add path alias to <code>tsconfig.json</code>
</li>
<li>
Add <code>JWT_SECRET</code>, <code>AUTH_HOST</code>,
<code>AUTH_INTERNAL_HOST</code>, <code>COOKIE_DOMAIN</code> to
<code>.env</code> and the server <code>.env</code>
</li>
<li>
In <code>index.ts</code>: <code>app.set('trust proxy', 1)</code>,
<code>app.use(cookieParser())</code>,
<code>app.use(jwtMiddleware)</code> — in that order, before routes
</li>
<li>
Use <code>requireAuth</code> on API routes,
<code>requireAuthPage</code> on page routes
</li>
<li>
Use <code>requireAccess(rules)</code> where role/product gating is needed
</li>
<li>
In browser TypeScript: replace <code>fetch</code> with <code>apiFetch</code>
from <code>auth-connector/browser/apiFetch</code>
</li>
<li>
Deploy and verify with
<code>journalctl -u &lt;service&gt; -f | grep '\[auth\]'</code>
</li>
</ol>
</div>
</section>
<footer>
rokojori-auth-connector
</footer>
</div>
<script>var NAV_ROOT = './';</script>
<script src="./_assets_/nav-data.js"></script>
<script src="./_assets_/nav.js"></script>
</body>
</html>