Skip to content

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:
typescript
{
  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 role field. 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_hash column of the auth_refresh_sessions table
  • Usage: POST /api/v1/auth/refresh with body { refreshToken }

Refresh Token Rotation

When renewing the access token via POST /api/v1/auth/refresh:

  1. Hash of the sent refresh token is validated against auth_refresh_sessions
  2. If the token has already been revoked (revoked_at not null) or replaced (replaced_by not null):
    • Sets suspected_compromise_at on the original session (possible token theft)
    • Revokes all of the user's sessions
    • Returns 401 COMPROMISED_SESSION
  3. Original session is marked with revoked_at and replaced_by pointing to the new one
  4. New session is created with new refresh token and new CSRF token
  5. 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:

VariableRole
AUTH_DEV_ADMIN_TOKENADMIN (userId: dev-admin)
AUTH_DEV_CALLER_TOKENCHAMADOR (userId: dev-caller)
AUTH_DEV_REPORTS_TOKENRELATORIOS (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:

typescript
// Example: only ADMIN and CADASTRO can create students
route(
  "POST",
  "/api/v1/students",
  [pipelineRequireAuth(), pipelineRequireRole(["ADMIN", "CADASTRO"])],
  handleCreateStudentPipeline,
);

Roles and Permissions

RolePermissions
ADMINFull access — manages users, roles, students, classes, nuclei, reports, attendance
CHAMADORMarks attendance (attendance), searches students (students.search)
RELATORIOSViews reports (reports), searches students (students.search)
CADASTRORegisters/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:

sql
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:

typescript
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

Distributed under MIT License.