Auth User Journeys¶
Backend authentication and authorization flows for the Dev Health platform. Each journey documents the API endpoint behavior, database operations, and response shapes.
All auth endpoints live under /api/v1/auth/ in src/dev_health_ops/api/auth/router.py.
Journey 1: Registration¶
A new user registers with email and password. The backend creates the user, an organization, and a membership in a single transaction, then sends a verification email asynchronously.
sequenceDiagram
participant C as Client
participant R as POST /register
participant V as Password Validator
participant DB as PostgreSQL
participant E as Email Service
C->>R: RegisterRequest {email, password, full_name?, org_name?}
R->>V: validate_password(password)
alt password violations
V-->>R: violations list
R-->>C: 422 {violations}
end
R->>DB: SELECT user WHERE email = normalized
alt email exists
R-->>C: 400 "Email already registered"
end
R->>DB: INSERT User (is_verified=false, auth_provider="local")
R->>DB: INSERT Organization (tier="community")
R->>DB: INSERT Membership (role="owner")
R->>DB: create_email_verification_token
R->>DB: COMMIT
R->>E: send_verification_email (async, non-blocking)
R-->>C: 201 RegisterResponse {message, user_id, org_id}
Rate limit: AUTH_REGISTER_LIMIT (3/hour per IP).
Key detail: Registration auto-creates org + membership, so newly registered users do NOT need onboarding (needs_onboarding=false on login).
Journey 2: Email Verification¶
User clicks the verification link from their email. The backend validates the token and marks the user as verified.
sequenceDiagram
participant C as Client
participant V as GET /verify
participant DB as PostgreSQL
C->>V: ?token=xxx
V->>DB: verify_email_token(token)
alt token invalid or expired
V-->>C: 400 "Invalid or expired verification token"
end
V->>DB: SET is_verified=true
V->>DB: COMMIT
V-->>C: 200 VerifyEmailResponse {message, verified: true}
Rate limit: 10/hour per IP.
Resend flow: POST /resend-verification accepts {email}, creates a new token, and resends. Returns a generic message regardless of whether the account exists (prevents enumeration). Rate limited to 3/hour.
Journey 3: Login (Happy Path — Verified User)¶
User submits credentials. Backend validates password, checks verification status, resolves membership, and returns tokens.
sequenceDiagram
participant C as Client
participant L as POST /login
participant DB as PostgreSQL
participant A as AuthService
C->>L: LoginRequest {email, password, org_id?}
L->>DB: check_lockout(email)
L->>DB: SELECT User WHERE email = normalized
L->>L: bcrypt.checkpw(password, hash)
Note over L: Constant-time comparison<br/>using DUMMY_PASSWORD_HASH<br/>even for missing users
L->>DB: clear_attempts(email)
L->>L: Check is_verified == true
L->>DB: SELECT Membership WHERE user_id
L->>DB: UPDATE last_login_at
L->>DB: emit_audit_log(LOGIN)
L->>DB: COMMIT
L->>A: create_token_pair(user_id, email, org_id, role)
L->>DB: INSERT refresh_token record
L-->>C: 200 LoginResponse {access_token, refresh_token, needs_onboarding, user}
Rate limits:
- AUTH_LOGIN_IP_LIMIT per IP
- AUTH_LOGIN_LIMIT per auth key
needs_onboarding: true only when user has no memberships and is not superuser. Since registration auto-creates a membership, this is typically false for self-registered users.
Journey 4: Login (Unverified Email)¶
User has valid credentials but has not verified their email address.
sequenceDiagram
participant C as Client
participant L as POST /login
participant DB as PostgreSQL
C->>L: LoginRequest {email, password}
L->>DB: check_lockout(email)
L->>DB: SELECT User WHERE email = normalized
L->>L: bcrypt.checkpw — password matches
L->>DB: clear_attempts(email)
L->>L: Check auth_provider == "local" AND is_verified == false
L->>DB: emit_audit_log(LOGIN_FAILED, "email not verified")
L->>DB: COMMIT
L-->>C: 200 EmailVerificationRequiredResponse {status, email, message}
Important: This returns HTTP 200 (not 401) with status: "email_verification_required". The frontend detects this response shape and shows an amber verification banner instead of an error toast.
Journey 5: Login (Invalid Credentials)¶
Password does not match, user does not exist, or account is disabled.
sequenceDiagram
participant C as Client
participant L as POST /login
participant DB as PostgreSQL
C->>L: LoginRequest {email, password}
L->>DB: check_lockout(email)
alt account locked
L-->>C: 429 {message, retry_after_seconds}
end
L->>DB: SELECT User WHERE email = normalized
L->>L: bcrypt.checkpw(password, hash_or_dummy)
L->>DB: record_failed_attempt(email)
L->>DB: emit_audit_log(LOGIN_FAILED)
L-->>C: 401 "Invalid credentials"
Failure reasons (all return 401 with same message):
- User not found
- Account disabled (is_active=false)
- No password hash (OAuth-only account)
- Password mismatch
Account lockout: After repeated failures, check_lockout returns true and the endpoint returns 429 with retry_after_seconds.
Journey 6: Onboarding¶
For users who authenticated but have no organization membership (e.g., invited users who haven't accepted yet). Supports two actions: create_org or join_org.
sequenceDiagram
participant C as Client
participant O as POST /onboard
participant DB as PostgreSQL
participant A as AuthService
C->>O: OnboardRequest {action, org_name?, invite_code?}
O->>DB: SELECT User WHERE id = jwt.sub
O->>DB: SELECT Membership WHERE user_id
alt already has membership
O-->>C: 400 "Already onboarded"
end
alt action == "create_org"
O->>DB: INSERT Organization
O->>DB: INSERT Membership (role="owner")
O->>DB: emit_audit_log(CREATE, ORGANIZATION)
else action == "join_org"
O->>DB: validate_org_invite(invite_code)
O->>DB: accept_org_invite — INSERT Membership
O->>DB: emit_audit_log(MEMBER_JOINED)
end
O->>DB: COMMIT
O->>A: create_token_pair (new tokens with org_id)
O-->>C: 200 OnboardResponse {tokens, org_id, org_name, role}
Requires authentication: JWT bearer token in Authorization header.
Journey 7: Password Reset¶
Two-step flow: request reset email, then submit new password with token.
flowchart TD
A[Client] -->|POST /forgot-password| B[Backend]
B --> C{User exists?}
C -->|No| D[Return generic message]
C -->|Yes| E[Create reset token]
E --> F[Send reset email]
F --> D
D --> G[Client receives 200]
H[Client] -->|POST /reset-password| I[Backend]
I --> J{Token valid?}
J -->|No| K[400 Invalid or expired]
J -->|Yes| L[Reset password]
L --> M[200 Password reset successful]
Anti-enumeration: POST /forgot-password always returns the same generic message regardless of whether the account exists.
Rate limit: 3/hour for forgot-password.
Journey 8: Invite Accept¶
Authenticated user accepts an organization invite. Creates membership and returns new tokens scoped to the organization.
sequenceDiagram
participant C as Client
participant AI as POST /accept-invite
participant DB as PostgreSQL
participant A as AuthService
C->>AI: AcceptInviteRequest {token} + Bearer JWT
AI->>DB: SELECT User WHERE id = jwt.sub
AI->>DB: validate_org_invite(token)
alt invite invalid
AI-->>C: 400 "Invalid or expired invite"
end
AI->>DB: SELECT Organization WHERE id = invite.org_id
AI->>DB: accept_org_invite — INSERT Membership
AI->>DB: emit_audit_log(MEMBER_JOINED)
AI->>DB: COMMIT
AI->>A: create_token_pair (scoped to new org)
AI-->>C: 200 AcceptInviteResponse {tokens, org_id, org_name, role}
Requires authentication: JWT bearer token in Authorization header.
Journey 9: Token Refresh¶
Client exchanges a refresh token for a new access token. Implements token rotation with reuse detection.
sequenceDiagram
participant C as Client
participant R as POST /refresh
participant DB as PostgreSQL
participant A as AuthService
C->>R: TokenRefreshRequest {refresh_token}
R->>A: validate_token(refresh_token, type="refresh")
alt token invalid
R-->>C: 401 "Invalid or expired refresh token"
end
R->>DB: find_by_hash(jti)
alt token revoked (reuse detected)
R->>DB: revoke_family(family_id)
R-->>C: 401 "Refresh token reuse detected"
end
R->>DB: SELECT User WHERE id = sub
R->>A: create_refresh_token (same family_id)
R->>DB: rotate_token(old_jti, new_jti)
R->>A: create_access_token
R->>DB: emit_audit_log(LOGIN, "Access token refreshed")
R-->>C: 200 TokenRefreshResponse {access_token, refresh_token, user}
Security: Refresh tokens are single-use. If a revoked token is reused, the entire token family is revoked (reuse detection).
Rate limit: AUTH_REFRESH_LIMIT.
Journey 10: Logout¶
Client submits refresh token for revocation.
sequenceDiagram
participant C as Client
participant L as POST /logout
participant DB as PostgreSQL
C->>L: LogoutRequest {refresh_token} + Bearer JWT (optional)
L->>L: validate refresh_token
alt valid refresh token
L->>DB: revoke_token(jti)
end
alt authenticated user
L->>DB: emit_audit_log(LOGOUT)
L->>DB: COMMIT
end
L-->>C: 200 {message: "Logout successful"}
Note: The bearer JWT is optional — logout still revokes the refresh token even without it.
Endpoint Reference¶
| Endpoint | Method | Auth | Rate Limit | Response |
|---|---|---|---|---|
/register |
POST | None | 3/hour | RegisterResponse (201) |
/verify |
GET | None | 10/hour | VerifyEmailResponse |
/resend-verification |
POST | None | 3/hour | VerifyEmailResponse |
/login |
POST | None | Per IP + key | LoginResponse or EmailVerificationRequiredResponse |
/forgot-password |
POST | None | 3/hour | VerifyEmailResponse |
/reset-password |
POST | None | None | VerifyEmailResponse |
/onboard |
POST | Bearer | None | OnboardResponse |
/accept-invite |
POST | Bearer | None | AcceptInviteResponse |
/refresh |
POST | None | Per limit | TokenRefreshResponse |
/validate |
POST | None | Per limit | TokenValidateResponse |
/me |
GET | Bearer | None | MeResponse |
/logout |
POST | Optional | None | {message} |
Security Notes¶
- Constant-time password comparison: Even for nonexistent users, bcrypt compares against
DUMMY_PASSWORD_HASHto prevent timing attacks. - Account lockout: Failed login attempts are tracked per email. After threshold, returns 429 with retry delay.
- Token rotation: Refresh tokens are single-use with family-based reuse detection.
- Anti-enumeration: Forgot-password and resend-verification return generic messages regardless of account existence.
- Audit logging: All auth events (login, logout, registration, failures) are recorded with IP and user-agent.