Lumen Docs

Auth

Endpoints under /auth/*. All use HTTP + JSON.

POST /auth/login

Public. Exchange credentials for a token pair.

Request:

{
  "email": "user@example.com",
  "password": "secret123"
}

Response 200:

{
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "name": "Jane Doe",
    "platformRole": "admin",
    "orgPosition": "member",
    "departmentId": "uuid-or-null",
    "avatarColor": "#2a6fdb"
  },
  "accessToken": "eyJhbGc...",
  "refreshToken": "eyJhbGc..."
}

Errors:

  • 401 {"error": "Invalid email or password"} — credentials wrong or user inactive
  • 503 {"error": "not_bootstrapped"} — platform not initialized yet

POST /auth/refresh

Public. Rotate expired access token using a refresh token.

Request:

{ "refreshToken": "eyJhbGc..." }

Response 200:

{
  "accessToken": "eyJhbGc...",
  "refreshToken": "eyJhbGc..."  // new refresh token (rotated)
}

Errors:

  • 401 — refresh token expired or invalid. Client should redirect to /login.

POST /auth/logout

Authenticated. Invalidates the refresh token server-side (access token continues to work until natural expiry).

Response 204 — empty.

GET /auth/me

Authenticated. Returns the current user (re-fetches from DB to pick up changes).

Response 200:

{
  "user": {
    "id": "uuid",
    "email": "...",
    "name": "...",
    "platformRole": "...",
    "orgPosition": "...",
    "departmentId": "...",
    "status": "active",
    "lastLoginAt": "2026-05-12T...",
    "createdAt": "2026-04-01T..."
  }
}

Used by apps/web/lib/auth.tsx to hydrate the auth context on page load.

Token format

JWT signed with HS256 using JWT_SECRET. Payload:

{
  "sub": "<user-id>",
  "iat": 1716912345,
  "exp": 1716913245  // 15 min for access, 7 days for refresh
}

Note: platform role is NOT in the token. After the access-control refactor, the API always fetches the user from DB via requireAuth — token carries only the ID. This means role changes take effect immediately, not at next refresh.

Client behavior

apps/web/lib/api.ts auto-refreshes on 401:

if (res.status === 401 && !skipAuth && this.refreshToken) {
  await this.refreshAccessToken();
  // retry original request with new token
  res = await fetch(url, { ...options, headers });
}

If refresh itself fails (401 from /auth/refresh), the client clears tokens and redirects to /login.

Password policy

Hash: bcryptjs with cost factor 10. Storage: users.password_hash.

Rules enforced at validation:

  • Min 8 characters (enforced by Zod on all create/update endpoints)
  • No other complexity rules yet

Gotcha: bcrypt hash shell escaping

When setting a password hash manually via SQL (e.g. in a reset runbook), do not interpolate the hash into a bash double-quoted command. The $ signs in $2b$12$... get eaten by the shell.

Wrong:

ssh jaeger "docker exec lumen-postgres psql -c \"UPDATE users SET password_hash = '$HASH' ...\""

Right: write the SQL to a file and pipe it:

cat > reset.sql <<'SQL'
UPDATE users SET password_hash = '$2b$12$aexliH42aMY6mG3QM0JiBuIyYdn75FzoqQaxd5HUXVwerGavIY5Ly' WHERE email = '...';
SQL

scp reset.sql jaeger:/tmp/
ssh jaeger "docker cp /tmp/reset.sql lumen-postgres:/tmp/ && \
  docker exec lumen-postgres psql -U lumen -d lumen -f /tmp/reset.sql"