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_atfield 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
- Register success: valid name+email+password → 201 with session and user
- Register returns user details: response includes name, timezone, day_start_time
- Register duplicate: same email twice → 409
- Register missing name: → 400 with details
- Register missing email: → 400 with details
- Register short password: <8 chars → 400
- Register invalid email: "not-an-email" → 400
- Register name trimmed: " Johnny " → stored as "Johnny"
- Register name too long: 101 chars → 400
- Login success: correct credentials → 200 with session and user (includes name)
- Login wrong password: correct email, wrong password → 401
- Login wrong email: non-existent email → 401 (same error as wrong password)
- Login missing fields: → 400
- Auth token works: use token from login → authenticated endpoint returns 200
- Logout: logout → 204, use same token → 401
- Session expiry: create session with past expiry → 401
- Email normalization: register "User@Example.COM", login "user@example.com" → success
- Password hash: check DB directly → password column is bcrypt hash, not plaintext
- Admin reset: valid admin token + email → 200, can login with new password
- 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)