Initial Commit
This commit is contained in:
commit
e2b37b8820
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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' } );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
var NAV_DATA = {
|
||||||
|
title: 'Workspace',
|
||||||
|
path: 'index.html',
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 <repo-url>
|
||||||
|
|
||||||
|
# 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 <your-service> -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 <service> -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>
|
||||||
Loading…
Reference in New Issue