Submodule

rokojori-auth-connector

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

What this provides

File structure

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

source/shared/types.ts

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

AuthPayload Tokens AccessRule

source/server/auth.ts

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

jwtMiddleware requireAuth requireAuthPage requireAccess

source/browser/apiFetch.ts

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

apiFetch setAuthRedirectUrl decodeTokenPayload

1 — Add the submodule

In your service repo

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

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

When the submodule is updated

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

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

After cloning a repo that uses this submodule

git clone --recurse-submodules <repo-url>

# Or if you already cloned without --recurse-submodules:
git submodule update --init

2 — TypeScript path alias (optional but recommended)

tsconfig.json

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

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

With the alias

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

Without the alias (direct relative path)

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

3 — Environment variables

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

# Required — must match the JWT_SECRET in rokojori-auth exactly
JWT_SECRET=...

# Public URL of the auth service
# Used when redirecting unauthenticated browsers to the login page
AUTH_HOST=https://account.rokojori.com

# Internal URL for server-to-server token refresh calls
# Set to the local port in production to bypass nginx TLS overhead
# Leave unset in dev — falls back to AUTH_HOST
AUTH_INTERNAL_HOST=http://localhost:3001

# Cookie domain — must match COOKIE_DOMAIN in rokojori-auth
COOKIE_DOMAIN=.rokojori.com

4 — Wire into index.ts

Minimal setup

import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import { jwtMiddleware, requireAuth, requireAuthPage } from 'auth-connector/server/auth';

const app = express();

app.set( 'trust proxy', 1 );   // required — real client IPs, secure cookie flag
app.use( express.json() );
app.use( cookieParser() );
app.use( jwtMiddleware );       // runs on every request before routes and static files

app.use( express.static( ... ) );

// API route — returns 401 JSON if not authenticated
app.get( '/api/me', requireAuth, ( req, res ) =>
{
  res.json( { userId: req.auth!.userId } );
} );

// Page route — redirects to login if not authenticated
app.get( '/dashboard', requireAuthPage, ( req, res ) =>
{
  res.sendFile( ... );
} );

With role/product gating

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

const MY_RULES: AccessRule[] = [
  { role: 'admin' },
  { role: 'user', product: 'my-product' },
];

// requireAccess includes its own 401/403 handling — no need to add requireAuth before it
app.get( '/api/premium', jwtMiddleware, requireAccess( MY_RULES ), handler );

5 — Browser SPA

Drop-in fetch replacement

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

import { apiFetch } from 'auth-connector/browser/apiFetch';

// Use apiFetch exactly like fetch — it redirects to login on 401
const res  = await apiFetch( '/api/projects' );
const data = await res.json();

Override the login URL

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

import { setAuthRedirectUrl } from 'auth-connector/browser/apiFetch';

setAuthRedirectUrl( 'https://account.rokojori.com/login.html' );

6 — What jwtMiddleware does, step by step

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

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

Normal request (valid token)

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

Expired access token, refresh token present (cookie client)

[auth] expired token on: GET /api/projects
[auth] tryRefresh → http://localhost:3001/api/auth/refresh
[auth] tryRefresh status: 200
[auth] tryRefresh: succeeded

1. jwt.verify() throws TokenExpiredError
2. Read refreshToken cookie
3. POST AUTH_INTERNAL_HOST/api/auth/refresh with the refresh token
4. Write new accessToken + refreshToken cookies onto the response
5. req.auth = decoded new access token
6. next() — route handler runs, client receives rotated cookies transparently

Expired access token, refresh token also expired

[auth] expired token on: GET /api/projects
[auth] tryRefresh → http://localhost:3001/api/auth/refresh
[auth] tryRefresh status: 401
[auth] tryRefresh error body: {"error":"Invalid or expired refresh token"}
[auth] refresh failed for: GET /api/projects

1. jwt.verify() throws TokenExpiredError
2. POST to refresh endpoint → 401
3. next() — req.auth remains undefined
4. requireAuth returns 401 JSON
   requireAccess returns 401 JSON or redirects to login (page routes)

Electron / Bearer-only client

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

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

7 — Checklist for a new service

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