Skip to main content

Feature: Auth - Email/Password

Version: 1.1.0 Last Reviewed: 2026-02-10 Status: Approved

User Story

As a parent, I can create an account and log in so my data is secure and tied to me.

MVP Scope

  • Register with name + email + password
  • Login, receive session token
  • Logout (invalidate session)
  • Token stored securely in iOS Keychain
  • Server URL configurable (self-hosted)

NOT in MVP

  • Magic links (Post-MVP #12)
  • OAuth (Google, Apple Sign In)
  • Password reset via email (reset via admin endpoint for MVP)
  • Email verification
  • Account deletion (can delete via DB)
  • Per-user rate limiting (global rate limit exists via Fastify plugin)

API Contract

Register

POST /api/v1/auth/register

Request:
{
"name": "Johnny",
"email": "parent@example.com",
"password": "securepassword123"
}

Response 201:
{
"session": {
"token": "abc123def456...",
"expires_at": "2026-03-12T12:00:00.000Z"
},
"user": {
"id": "uuid",
"email": "parent@example.com",
"name": "Johnny",
"timezone": "America/New_York",
"day_start_time": "07:00"
}
}

Response 400:
{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } }

Response 409:
{ "error": { "code": "CONFLICT", "message": "Email already registered", "details": [] } }

Login

POST /api/v1/auth/login

Request:
{
"email": "parent@example.com",
"password": "securepassword123"
}

Response 200:
{
"session": {
"token": "abc123def456...",
"expires_at": "2026-03-12T12:00:00.000Z"
},
"user": {
"id": "uuid",
"email": "parent@example.com",
"name": "Johnny",
"timezone": "America/New_York",
"day_start_time": "07:00"
}
}

Response 401:
{ "error": { "code": "UNAUTHORIZED", "message": "Invalid email or password", "details": [] } }

Logout

POST /api/v1/auth/logout
Authorization: Bearer <token>

Response 204: (No Content)

Admin Password Reset (MVP escape hatch)

POST /api/v1/auth/reset-password
X-Admin-Token: <JWT_SECRET from .env>

Request:
{
"email": "parent@example.com",
"new_password": "newsecurepassword123"
}

Response 200:
{ "success": true }

Note: Protected by server-side admin token. Emergency use only.
Proper password reset flow is post-MVP.

Error Responses

Standard error format:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body validation failed",
"details": [
{ "field": "email", "message": "Invalid email format", "code": "invalid_string" }
]
}
}

Error codes: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 409 CONFLICT

Security Requirements

  • Password hashing: bcrypt with 12 rounds (cost factor)
  • Password minimum: 8 characters (no max, no complexity requirements for MVP)
  • Name handling: Required, 1-100 characters, trimmed (same validation as child name)
  • Email handling: Lowercase and trim before storage and comparison
  • Token generation: crypto.randomBytes(32).toString('hex') (64 hex chars)
  • Session expiry: Rolling 30-day inactivity timeout. Session expires 30 days after last use, not creation. The last_verified_at field is updated on each authenticated request, but only if ≥1 hour has passed since the last update (reduces DB writes). No absolute timeout — admin can force-invalidate by deleting the session record if needed.
  • Expired session cleanup: Delete on auth check (lazy cleanup). Sessions inactive for >30 days are deleted when encountered.
  • Error messages: Never reveal whether an email exists ("Invalid email or password" for both wrong email and wrong password)
  • Login timing: bcrypt.compare provides constant-time comparison to prevent timing attacks

Audit Logging

  • Register success: AuditLog entry with entity_type="user", action="create", changes={email, name} (never password)
  • Login success: AuditLog entry with entity_type="session", action="create", changes={user_id}
  • Login failure: NOT logged (prevents audit log flooding from brute force)
  • Logout: AuditLog entry with entity_type="session", action="delete", changes={user_id}
  • Password reset: AuditLog entry with entity_type="user", action="update", changes={password_changed: true} (never the actual password)

Acceptance Criteria

  • Can register with name + email + password, receive session + user
  • User response includes name, timezone, and day_start_time
  • Name is required at registration (1-100 chars, trimmed)
  • Session response includes token and expires_at
  • Duplicate email returns 409
  • Can login with correct credentials
  • Wrong password returns 401 with generic message
  • Wrong email returns 401 with same generic message
  • Token works as Bearer auth for authenticated endpoints
  • Logout invalidates the session (returns 204)
  • Active sessions have their expiry extended on each authenticated request (rolling, checked hourly)
  • Inactive tokens (>30 days since last use) return 401
  • Passwords stored as bcrypt hashes (never plaintext)
  • Email stored lowercase and trimmed

Test Cases

  1. Register success: valid name+email+password → 201 with session and user
  2. Register returns user details: response includes name, timezone, day_start_time
  3. Register duplicate: same email twice → 409
  4. Register missing name: → 400 with details
  5. Register missing email: → 400 with details
  6. Register short password: <8 chars → 400
  7. Register invalid email: "not-an-email" → 400
  8. Register name trimmed: " Johnny " → stored as "Johnny"
  9. Register name too long: 101 chars → 400
  10. Login success: correct credentials → 200 with session and user (includes name)
  11. Login wrong password: correct email, wrong password → 401
  12. Login wrong email: non-existent email → 401 (same error as wrong password)
  13. Login missing fields: → 400
  14. Auth token works: use token from login → authenticated endpoint returns 200
  15. Logout: logout → 204, use same token → 401
  16. Session expiry: create session with past expiry → 401
  17. Email normalization: register "User@Example.COM", login "user@example.com" → success
  18. Password hash: check DB directly → password column is bcrypt hash, not plaintext
  19. Admin reset: valid admin token + email → 200, can login with new password
  20. Admin reset bad token: wrong admin token → 401

Boundaries

  • No email verification
  • No password complexity rules beyond minimum 8 characters
  • No password reset via email (admin-only endpoint for MVP)
  • No account lockout after failed attempts
  • No multi-device session management (each login creates new session)
  • User profile management (timezone, password change) is separate spec (user-profile.md)