Authentication and Authorization
Neemias' authentication system uses PBKDF2 for password hashing and JWT HS256 (via the jose library) for access tokens, with refresh token rotation to detect malicious reuse.
Login Flow
POST /api/v1/auth/login
Body: { email, password }
1. Validates body with Zod (schemas.ts)
2. Looks up user by email (readAuthUserByEmail)
3. Verifies status = ACTIVE
4. Verifies password with PBKDF2 (verifyPassword)
5. Creates session (createAuthSession):
- Generates refresh token (crypto.randomUUID)
- Generates CSRF token (crypto.randomUUID)
- Generates sessionId (crypto.randomUUID)
- Hash of refresh token stored in auth_refresh_sessions
- Signs JWT access token (TTL 15 min)
6. Returns { accessToken, refreshToken, csrfToken, expiresAt }Rate Limiting
The login route is protected by rate limiting: 5 requests per minute per IP. The refresh route allows 10 requests per minute.
Tokens
Access Token (JWT)
- Algorithm: HS256 (symmetric, key
AUTH_JWT_SECRET) - TTL: 15 minutes (configurable via
AUTH_ACCESS_TOKEN_TTL_SECONDS) - Payload:
{
userId: string; // User ID
roles: UserRole[]; // Array of assigned roles (v0.56.0+)
primaryRole: UserRole; // Primary role for display
sessionId: string; // Session ID
csrfToken: string; // Session CSRF token
iat: number; // Issued at
exp: number; // Expiration
}Legacy: v0.55.x and earlier used a single
rolefield. The JWT middleware still supports the legacy format for backward compatibility during rollout.
- Usage: Header
Authorization: Bearer <accessToken>
Refresh Token
- Format: UUID v4 (opaque)
- TTL: 7 days (configurable via
AUTH_REFRESH_TOKEN_TTL_SECONDS) - Storage: SHA-256 hash of the token in the
refresh_token_hashcolumn of theauth_refresh_sessionstable - Usage:
POST /api/v1/auth/refreshwith body{ refreshToken }
Refresh Token Rotation
When renewing the access token via POST /api/v1/auth/refresh:
- Hash of the sent refresh token is validated against
auth_refresh_sessions - If the token has already been revoked (
revoked_atnot null) or replaced (replaced_bynot null):- Sets
suspected_compromise_aton the original session (possible token theft) - Revokes all of the user's sessions
- Returns
401 COMPROMISED_SESSION
- Sets
- Original session is marked with
revoked_atandreplaced_bypointing to the new one - New session is created with new refresh token and new CSRF token
- Returns new pair
{ accessToken, refreshToken, csrfToken }
CSRF Token
Each session has a csrfToken (UUID v4). It is part of the JWT payload and returned on login/refresh. The frontend stores it and sends it in mutation headers (used by syncQueueEntryWithBackend).
X-Device-Id
Required on every authenticated request. The requireAuth middleware (in workers/src/middleware/auth.ts) rejects with 400 VALIDATION_FAILED if the X-Device-Id header is missing.
Dev Mode
When the AUTH_MODE=dev environment variable is set, the backend accepts fixed tokens defined in env vars:
| Variable | Role |
|---|---|
AUTH_DEV_ADMIN_TOKEN | ADMIN (userId: dev-admin) |
AUTH_DEV_CALLER_TOKEN | CHAMADOR (userId: dev-caller) |
AUTH_DEV_REPORTS_TOKEN | RELATORIOS (userId: dev-reports) |
Important: Dev mode completely bypasses JWT validation. Never use in production.
Access Control (RBAC)
The requireRole(roles[]) middleware checks whether AuthPrincipal.role is in the allowed list:
// Example: only ADMIN and CADASTRO can create students
route(
"POST",
"/api/v1/students",
[pipelineRequireAuth(), pipelineRequireRole(["ADMIN", "CADASTRO"])],
handleCreateStudentPipeline,
);Roles and Permissions
| Role | Permissions |
|---|---|
| ADMIN | Full access — manages users, roles, students, classes, nuclei, reports, attendance |
| CHAMADOR | Marks attendance (attendance), searches students (students.search) |
| RELATORIOS | Views reports (reports), searches students (students.search) |
| CADASTRO | Registers/edits students (students.add, students.search), manages sessions (sessions.add, sessions.edit), nuclei, classes |
roles Table
Starting from migration 0006_roles.sql, permissions are persisted in the database:
CREATE TABLE roles (
name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
permissions TEXT NOT NULL DEFAULT '[]', -- JSON array
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);Roles with is_system = 1 cannot be deleted.
Password Hash
Stored format: pbkdf2:<hexSalt>:<hexHash>
- Algorithm: PBKDF2
- Iterations: 100,000
- Hash: SHA-256
- Salt: 16 random bytes
Historical note: The legacy backend used scrypt, but it was incompatible with the Workers runtime. The migration to PBKDF2 was resolved at project initialization.
AuthPrincipal
The authentication result populated in ctx.principal:
interface AuthPrincipal {
userId: string;
role: UserRole;
tokenExpiresAt?: number; // present only in real JWT (not dev mode)
sessionId?: string;
csrfToken?: string;
}Source: workers/CONTEXT.md + auth.ts