design phase 1 auth - sinrimin/harness-nexus GitHub Wiki
Status: implemented in Phase 1. Covers two roles, JWT access tokens, PAT, a toggleable registration switch, and front- and back-end permission guards.
Exactly two roles. No third role will be added in Phase 1.
| Role | Capabilities |
|---|---|
user |
Manage own resources/profiles/PATs; read global resources/profiles. |
admin |
Everything a user can do, plus user management and system settings. |
A user holds exactly one role (User.role, not an array). All authorization
decisions branch on this single field.
type Role = 'admin' | 'user';
interface User {
id: string;
username: string;
email?: string;
passwordHash: string | null; // argon2id; null only for future passwordless accounts
role: Role;
status: 'active' | 'disabled';
createdAt: string;
updatedAt: string;
}A single-row aggregate persisted in system_settings. Phase 1 uses one field.
interface SystemSettings {
allowRegistration: boolean; // default true on first boot
updatedAt: string;
}Both arrive via Authorization: Bearer <credential>. The auth plugin distinguishes
them by prefix:
-
JWT access token (primary, for the web UI). Issued by
POST /api/auth/loginandPOST /api/auth/register. Format: a standard JWT signed withJWT_SECRET. Lifetime: 7 days (configurable viaJWT_ACCESS_TTL). Stateless — the server verifies the signature and expiry, no DB lookup needed. -
PAT (for CLI / automation / SDK). Format:
hnpat_<base64url(32 random bytes)>. Only its sha256 is stored. Resolved via a DB lookup on each request;lastUsedAtis updated fire-and-forget.
After the onRequest hook, both channels produce the same shape on the request:
req.user: { id: string; role: Role } | nullFailed or missing auth leaves req.user = null (no error thrown at this stage —
route guards decide whether that's allowed).
Decorated onto the Fastify instance. They are preHandler hooks attached per route (or per router prefix), not global:
app.get('/api/auth/me', { preHandler: [app.requireAuth] }, handler);
app.post('/api/users', { preHandler: [app.requireAdmin] }, handler);-
requireAuth:req.user === null→401 { error: 'UNAUTHORIZED' }. -
requireAdmin:requireAuthfirst, thenreq.user.role !== 'admin'→403 { error: 'FORBIDDEN' }.
Public routes (/healthz, /readyz, /api/auth/login, /api/auth/register
when open) attach neither guard.
React Router layout routes:
-
<RequireAuth>: if no token/user → redirect to/login?from=…. -
<RequireAdmin>: ifuser.role !== 'admin'→ render a 403 view (no redirect loop).
Additionally the API wrapper centralizes the 401 interceptor: any fetch
returning 401 clears the stored token/user and redirects to /login.
-
SystemSettings.allowRegistrationdefaults to true on first boot. -
POST /api/auth/register:- If
allowRegistration === false→403 REGISTRATION_DISABLED. (Admins usePOST /api/usersto add accounts, which bypasses the switch.) - If the
userstable is empty, the new user is created with roleadmin(the bootstrap admin). Subsequent registrations get roleuser.
- If
-
PUT /api/settings/registration(admin only) toggles the switch.
Rationale: the first registrant becomes admin so a fresh self-hosted instance is usable without env vars or CLI steps. Registration is not auto-closed after bootstrap — the admin decides.
All under /api, JSON bodies. { error, message } error shape.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/auth/register |
public* | *only when allowRegistration; first user → admin |
| POST | /api/auth/login |
public | returns { token } JWT |
| GET | /api/auth/me |
requireAuth |
current user |
| POST | /api/users |
requireAdmin |
create user, bypasses registration switch |
| GET | /api/users |
requireAdmin |
list users |
| DELETE | /api/users/:id |
requireAdmin |
no self-delete, no deleting the last admin |
| PATCH | /api/users/:id/role |
requireAdmin |
change role; cannot demote the last admin |
| POST | /api/pats |
requireAuth |
create PAT, returns plaintext once |
| GET | /api/pats |
requireAuth |
list caller's own PATs |
| DELETE | /api/pats/:id |
requireAuth |
revoke own PAT |
| GET | /api/settings/registration |
public |
{ allowRegistration } — login/register pages need it |
| PUT | /api/settings/registration |
requireAdmin |
{ allowRegistration } |
* /api/settings/registration GET is intentionally public so the register page
can show/hide itself without a login.
-
JWT: stateless. There is no built-in revocation. Logout is client-side
(drop the token). Rotating
JWT_SECRETinvalidates all outstanding tokens. -
PAT: stored as sha256 hash + prefix. Revocable by
DELETE /api/pats/:id. - Password hashes use argon2id (
@node-rs/argon2), OWASP-recommended params.
-
Last-admin protection:
DELETE /api/users/:idandPATCH :id/roleboth count remaining admins and refuse to remove/demote the last one (409 LAST_ADMIN). -
No self-delete: a user cannot delete their own account (
409 NO_SELF_DELETE). -
Password rules: 8–256 chars. Username
^[\w.-]{3,32}$, unique. -
Disabled users: a disabled user's token is rejected at the
verifyAccessTokenboundary by a fresh user lookup on protected routes (kept cheap via a small TODO cache; for Phase 1 it's a direct DB read).
Zod schemas in packages/shared/src/schemas/auth.ts. Server, SDK, and (via
inferred types) the web client share them. Password/username rules live there.