Sunday, 13 July 2026

rokojori-auth built + Roject integration

Full centralized auth service created and deployed to account.rokojori.com; Roject's local user system replaced with JWT verification against rokojori-auth.

What we built

rokojori-auth — new standalone service

A complete auth service at c:\rokojori\projects\web-projects\rokojori-auth, same stack as Roject (Node.js, Express, ts-node, JSON file storage). Built entirely from scratch this session. Deployed to account.rokojori.com via nginx + systemd + Let's Encrypt on Server A, port 3001.

account.rokojori.com nginx + systemd port 3001

Auth endpoints

Register, login, logout, refresh (body), refresh-session (cookie + redirect), forgot-password, reset-password, GET /me, PATCH /me/settings, POST /me/password, DELETE /me. Access token is a 1-hour HS256 JWT containing userId, email, roles, products, settings. Refresh token is a 30-day UUID stored server-side. Both are set as HttpOnly cookies on .rokojori.com and also returned in the response body for non-browser clients.

jsonwebtoken bcryptjs cookie-parser HttpOnly cookie .rokojori.com domain

Roles: user, admin, superadmin

Three built-in roles. superadmin is bootstrapped via INITIAL_SUPERADMIN_EMAIL in .env — fires once on first registration if no superadmin exists. Superadmin can grant/revoke admin on other users; cannot change their own roles. Admin and superadmin get an enhanced profile page with a full user list. Role management controls are superadmin-only.

Rate limiting

In-memory per-IP rate limiters on all sensitive endpoints. Login: 10 attempts / 15 min, 3 s delay after attempt 5. Register: 5 / hour. Forgot-password: 20 / 20 min with escalating delay (5 s → 15 s → 30 s). trust proxy enabled so real client IPs are seen behind nginx.

HTML pages

Five self-contained pages: login, register, profile, forgot-password, reset-password. Plain HTML with inline CSS and JS. Login and register accept a ?redirect= query param. Reset-password fetches the email for the token via GET /api/auth/reset-token-email and populates a hidden email field so browsers offer to update the saved password. Profile page includes change-password (with current password verification) and a two-step delete account confirmation. Welcome email sent on registration (non-blocking).

Admin routes

GET /api/admin/users (admin + superadmin) lists all users without password hashes. PATCH /api/admin/users/:id/roles (superadmin only) sets roles on another user. Guards: cannot change own roles; user base role is always preserved.

Workspace documentation

Created workspace/ in the rokojori-auth repo with the same asset system as the Roject workspace (styles, nav, breadcrumb). A single workspace/index.html covers features, technical implementation, data model, JWT payload, deployment, planned work, and a reference to the Roject workspace for guides and actions.

Key decisions

Refresh token also set as HttpOnly cookie

Originally the refresh token was only returned in the response body. Added a refresh token cookie so browser clients can silently refresh via GET /api/auth/refresh-session?redirect=... without re-login. Non-browser clients continue to use the body token and POST /api/auth/refresh.

Superadmin cannot change their own roles

The PATCH /api/admin/users/:id/roles endpoint blocks changes to the calling user's own record to prevent accidental self-demotion or lockout.

Welcome email is non-blocking

SMTP failures on the welcome email are caught and silently ignored so that a misconfigured mail server cannot break user registration.

Forgot-password always returns OK

The response is always { ok: true } regardless of whether the email exists, preventing account enumeration via timing or response differences.

What we built — Roject integration

Local auth system removed

Deleted routes/auth.ts, sessionStore.ts, email/, login.html, register.html, and the login-form and register-form components. Removed bcryptjs and express-session from package.json. Removed the User model and users store from db.ts.

JWT verification middleware

New source/server/middleware/auth.ts reads the accessToken cookie (or Authorization: Bearer header), verifies it with the shared JWT_SECRET, and attaches req.user. Expired tokens on page requests are transparently redirected to account.rokojori.com/api/auth/refresh-session?redirect=.... API routes return 401 when unauthenticated. Added cookie-parser and jsonwebtoken.

app-nav updated

app-nav.ts now calls GET /api/auth/me on Roject (which returns req.user from the JWT). On 401 it renders a "Log in" link pointing to account.rokojori.com/login.html?redirect=https://roject.rokojori.com. When authenticated it shows the user's email and a logout link to account.rokojori.com/api/auth/logout?redirect=....

GET /logout added to rokojori-auth

Added GET /api/auth/logout?redirect=... to rokojori-auth, mirroring the refresh-session pattern. Clears both cookies, invalidates the refresh token, and redirects to the given URL.

systemd EnvironmentFile

Roject's systemd service had no environment variables configured, so JWT_SECRET was silently empty and all JWT verifications failed. Fixed by adding EnvironmentFile=/opt/roject/.env via systemctl edit roject followed by a daemon-reload and restart.

What we built — CI/CD pipeline

Webhook-based auto-deploy

Replaced the manual git pull && npm run build && systemctl restart roject deploy with a Gitea webhook calling POST /api/deploy on the Roject server itself. The endpoint verifies the X-Gitea-Signature HMAC-SHA256 signature against DEPLOY_WEBHOOK_SECRET, checks the push is to refs/heads/main, responds 200 immediately, then spawns a detached bash process that pulls, builds, and restarts the service — surviving the systemctl restart that kills the parent.

Gitea webhook HMAC-SHA256 detached spawn /api/deploy

Why not Gitea Actions runner

Spent significant time attempting a gitea-runner (act_runner) setup. The runner requires Docker to execute job steps; without it the :host execution mode produced a path-resolution bug. Installing Docker would have made systemctl restart from inside a container awkward. A direct webhook to the server is simpler, fully transparent, and fits a single-server deploy perfectly.

What's next

Landing screen for unauthenticated users — instead of crashing on dashboard components, show a page that explains the app and offers a login link. Also: graceful 401 handling in data-fetching components (groups, projects, etc.) so they display a sensible message instead of throwing a JS error.

CI improvements planned: send an email notification after each auto-deploy restart; switch webhook trigger to a dev branch to avoid deploying on every commit to main.