369 lines
14 KiB
HTML
369 lines
14 KiB
HTML
<!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 git@community.rokojori.com:Rokojori/rokojori-auth-connector.git source/auth-connector
|
|
git submodule update --init</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
This creates <code>source/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 source/auth-connector
|
|
git add source/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>../../source/auth-connector/source/...</code> paths.
|
|
</p>
|
|
<pre><code>{
|
|
"compilerOptions": {
|
|
"paths": {
|
|
"auth-connector/*": ["./source/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 '../../source/auth-connector/source/server/auth';
|
|
import type { AuthPayload } from '../../source/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>source/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>
|