Plan

Auth Connector Rewrite

Per-repo change list for adopting rokojori-auth-connector as a git submodule. Read in full before touching any file. Changes are listed in dependency order within each repo — do them top to bottom.

Overview

What the connector replaces

Every service currently has its own copy of auth middleware. They have drifted in three ways that cause constant breakage:

  • No transparent refresh — tunnel and styles only have a bare requireAuth. An expired access token always returns 401 with no recovery attempt, even when a valid refresh token is present.
  • Wrong property name — roject uses req.user; tunnel, styles, and rokojori-auth use req.auth. Routes cannot be safely copied between services.
  • ACCESS_TOKEN_TTL set to 10 seconds — the single most likely cause of constant auth failures. Every session is broken 10 seconds after login unless the transparent refresh is working perfectly.

Repos in scope

rokojori-auth   — fix ACCESS_TOKEN_TTL only; does NOT get the submodule
roject          — add submodule; delete local auth.ts; rename req.user → req.auth (19 places)
styles          — add submodule; delete local requireAuth.ts + requireAccess.ts; add jwtMiddleware
tunnel          — add submodule; delete local requireAuth.ts; add jwtMiddleware for API routes

Do this first, before any other change

Fix the TTL bug in rokojori-auth and redeploy. Every other fix depends on transparent refresh working, and transparent refresh being tested every 10 seconds instead of every hour makes everything harder to reason about.

rokojori-auth

Does NOT get the submodule

rokojori-auth is the auth service itself. It cannot call itself for token refresh, so jwtMiddleware makes no sense here. Its own requireAuth.ts is correct for guarding its own API routes (/api/auth/me etc.) and should not change.

Change 1 — Fix ACCESS_TOKEN_TTL

File: source/server/routes/auth.ts, line 13

// Before
const ACCESS_TOKEN_TTL = '10s';

// After
const ACCESS_TOKEN_TTL = '1h';

Redeploy immediately after this change. Restart the service and confirm with journalctl -u rokojori-auth -n 20 that it came up cleanly.

Nothing else changes

The page-level redirect middleware in source/server/index.ts is correct for a pure server-rendered auth service — leave it as-is. The requireAuth.ts middleware is correct for the auth service's own routes — leave it as-is.

roject

Current state

Roject is the most advanced of the three — it already has jwtMiddleware with transparent refresh. The problems are:

  • Uses req.user instead of req.auth (19 call sites across 6 files)
  • Uses local type JwtUser instead of AuthPayload
  • jwtMiddleware still redirects non-API requests to AUTH_HOST/api/auth/refresh-session — unnecessary because server-side transparent refresh already handles page requests silently
  • Missing app.set('trust proxy', 1) in index.ts
  • Electron main.ts intercepts will-redirect to handle the refresh-session redirect — can be removed once the redirect branch is gone

Change 1 — Add the submodule

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

Change 2 — tsconfig path alias

Add to tsconfig.json (or the relevant tsconfig for server compilation):

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

Change 3 — Delete source/server/middleware/auth.ts

The entire file is replaced by the connector. Delete it after the imports in all dependents are updated (changes 4–7 below).

Change 4 — Update source/server/index.ts

  • Add app.set( 'trust proxy', 1 ); before app.use( express.json() )
  • Change import: from 'auth-connector/server/auth'
  • Line 35: req.userreq.auth
// Before
import { jwtMiddleware, requireAuth } from './middleware/auth';
...
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.user ) );

// After
import { jwtMiddleware, requireAuth } from 'auth-connector/server/auth';
...
app.set( 'trust proxy', 1 );
...
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.auth ) );

Change 5 — Update source/server/projectAccess.ts

Replace the local JwtUser import with AuthPayload from the connector. The type shape is identical — this is a rename only.

// Before
import { JwtUser } from './middleware/auth';
export function isOwner( project: Project, user: JwtUser ): boolean { ... }
// ... all function signatures use JwtUser

// After
import type { AuthPayload } from 'auth-connector/shared/types';
export function isOwner( project: Project, user: AuthPayload ): boolean { ... }
// ... replace JwtUser with AuthPayload in all 4 function signatures

Change 6 — Rename req.user → req.auth in route files

19 occurrences across 5 files. All are mechanical replacements — the shape of the object does not change.

  • source/server/routes/files.ts — 7 occurrences
  • source/server/routes/projects.ts — 7 occurrences
  • source/server/routes/layout.ts — 2 occurrences
  • source/server/routes/rojos.ts — 2 occurrences
  • source/server/routes/userSettings.ts — 2 occurrences

The checkAccess calls pass req.user! as the second argument. After change 5, projectAccess.ts expects AuthPayload — the rename makes the types consistent. Change every req.user to req.auth and every req.user! to req.auth!.

Change 7 — Remove the non-API redirect branch from jwtMiddleware

In the old local auth.ts (now deleted), jwtMiddleware redirected non-API requests with an expired token to AUTH_HOST/api/auth/refresh-session. The connector's jwtMiddleware does not do this — it handles the refresh server-side and never redirects. This is correct behavior. No explicit action needed here once the old file is deleted.

Change 8 — Remove the will-redirect intercept from electron/main.ts

The mainWindow.webContents.on('will-redirect', ...) block (lines 136–158) exists solely to intercept the /api/auth/refresh-session redirect that the old jwtMiddleware emitted. Once the redirect is gone, this intercept is dead code and should be removed.

// Remove this entire block from electron/main.ts:
mainWindow.webContents.on( 'will-redirect', async ( event, url ) =>
{
  if ( url.includes( '/api/auth/refresh-session' ) )
  {
    // ... entire block
  }
} );

The refreshTokens() helper function defined above it can also be deleted — Electron no longer needs to do its own refresh because the server-side jwtMiddleware handles it transparently.

Verify

journalctl -u roject -f | grep '\[auth\]'

Log into Roject, wait 65 minutes (or temporarily set ACCESS_TOKEN_TTL=65s in the test environment), then make an API call. Expect to see the [auth] tryRefresh → ... sequence and a transparent recovery.

styles

Current state

styles has no jwtMiddleware at all. requireAuth is wired directly onto routes. An expired access token causes an immediate 401 with no recovery attempt, regardless of whether the refresh token is valid. STYLES_RULES is currently defined inside middleware/requireAccess.ts — it needs to move to index.ts.

Change 1 — Add the submodule

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

Change 2 — tsconfig path alias

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

Change 3 — Delete both middleware files

source/server/middleware/requireAuth.ts    ← delete
source/server/middleware/requireAccess.ts  ← delete

Do this after updating index.ts so the service never references the deleted files.

Change 4 — Rewrite source/server/index.ts imports and wiring

// Before
import { requireAuth }                    from './middleware/requireAuth';
import { requireAccess, STYLES_RULES }    from './middleware/requireAccess';

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

const STYLES_RULES: AccessRule[] = [
  { role: 'admin' },
  { role: 'user', product: 'styles' },
  { role: 'user', product: 'premium' },
];

Then add jwtMiddleware as global middleware — place it after cookieParser() and before the route registrations:

app.use( cookieParser() );
app.use( jwtMiddleware );    // ← add this line

// rest of routes unchanged...

The route registrations themselves do not change — they already use requireAuth and requireAccess( STYLES_RULES ).

Verify

journalctl -u styles-rokojori -f | grep '\[auth\]'

Hit /api/fonts with an expired token and a valid refresh cookie. Expect transparent recovery in the log.

tunnel

Current state

tunnel has no jwtMiddleware. Expired tokens on /api/tunnels routes always return 401 with no recovery. Two routes do their own inline JWT handling and must be treated carefully:

  • routes/agent.ts — handles WebSocket upgrades; cannot use Express middleware. Has its own jwt.verify() call. This is correct and does not change — WebSocket upgrades bypass Express middleware entirely.
  • routes/proxy.tssoftAuth() does a non-blocking JWT check for the proxy route. Public tunnels pass even without a token. This intentional soft auth stays local to proxy.ts — do not replace it with jwtMiddleware.

Change 1 — Add the submodule

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

Change 2 — tsconfig path alias

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

Change 3 — Delete source/server/middleware/requireAuth.ts

Delete after updating all importers below.

Change 4 — Update source/server/index.ts

Add jwtMiddleware scoped to the /api/tunnels routes. The proxy route (/t) must not get jwtMiddleware — it does its own soft auth.

// Before
import { requireAuth } from './middleware/requireAuth'; // (was unused at index level)

// After
import { jwtMiddleware } from 'auth-connector/server/auth';

// Add jwtMiddleware only for the API routes section:
app.use( '/api/tunnels', jwtMiddleware, express.json(), tunnelsRouter );

// The /t proxy route stays unchanged — no jwtMiddleware
app.use( '/t', proxyRouter );

Change 5 — Update routes/tunnels.ts

// Before
import { requireAuth } from '../middleware/requireAuth';

// After
import { requireAuth } from 'auth-connector/server/auth';

All uses of req.auth in this file are already correct — no other changes needed.

Change 6 — Update routes/proxy.ts

proxy.ts imports AuthPayload and extractBearer from the old local requireAuth.ts. extractBearer is internal to the connector and not exported. Inline it locally — it is three lines.

// Before
import { AuthPayload, extractBearer } from '../middleware/requireAuth';

// After
import type { AuthPayload } from 'auth-connector/shared/types';

function extractBearer( req: Request ): string | undefined
{
  const h = req.headers.authorization;
  return h?.startsWith( 'Bearer ' ) ? h.slice( 7 ) : undefined;
}

The softAuth() function and all other logic in proxy.ts stays unchanged.

Change 7 — Update routes/agent.ts

// Before
import { AuthPayload } from '../middleware/requireAuth';

// After
import type { AuthPayload } from 'auth-connector/shared/types';

The inline jwt.verify() in the WebSocket upgrade handler is correct and stays — WebSocket upgrades bypass Express middleware.

Verify

journalctl -u tunnel-rokojori -f | grep '\[auth\]'

Make an authenticated API call to /api/tunnels with an expired access token and a valid refresh cookie. Expect transparent recovery in the log. Then make a request to a public tunnel (/t/:id/...) without any token — expect it to pass through without hitting the auth log at all.

Execution order

  1. rokojori-auth — fix ACCESS_TOKEN_TTL to '1h', redeploy, verify startup log.
  2. Create the Gitea repo for rokojori-auth-connector and push the local directory to it.
  3. roject — add submodule, make all changes, redeploy. This is the most complex change (19 renames + Electron cleanup). Test thoroughly before moving to the others.
  4. styles — add submodule, rewire index.ts, delete old middleware files, redeploy.
  5. tunnel — add submodule, add jwtMiddleware to API routes, update imports in tunnels/proxy/agent, delete old requireAuth.ts, redeploy.

After each repo

Before moving to the next repo: confirm auth still works end-to-end — login, wait for the access token to expire (check with journalctl ... | grep '[auth]'), and confirm the first API call after expiry succeeds transparently.