rokojori-auth-connector/workspace/index.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 https://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 &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>../../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 &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>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 &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>