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.
Source-of-truth tests for the first-run path: tests/test_onboarding.py, tests/api/auth/test_register.py, tests/api/auth/test_onboarding_state.py, tests/api/auth/test_onboarding_skip.py, tests/api/test_new_user_journey.py, and tests/api/admin/test_setup_status.py.
Journey 1: Registration¶
A new user registers with email and password. In the first-run onboarding model, identity creation is separate from workspace creation. The rollout is gated by AUTH_AUTO_CREATE_ORG_ON_REGISTER, which defaults to true (production-preserving): registration creates the user identity and a community organization + owner membership. When the flag is set to false (guided-onboarding mode), registration creates only the user identity and email-verification token, and organization/workspace creation happens later through the explicit onboarding workspace step. A verification email is sent asynchronously in both modes.
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")
alt AUTH_AUTO_CREATE_ORG_ON_REGISTER=true (default, legacy)
R->>DB: INSERT Organization + owner Membership
end
R->>DB: create_email_verification_token
R->>DB: COMMIT
R->>E: send_verification_email (async, non-blocking)
alt AUTH_AUTO_CREATE_ORG_ON_REGISTER=true (default)
R-->>C: 201 RegisterResponse {message, user_id, org_id}
else flag=false (guided onboarding)
R-->>C: 201 RegisterResponse {message, user_id, org_id: null}
end
Rate limit: AUTH_REGISTER_LIMIT (3/hour per IP).
Key detail: Registration creates the identity only when AUTH_AUTO_CREATE_ORG_ON_REGISTER=false. Newly registered and verified users authenticate with orgless tokens and route to the workspace step. The temporary compatibility flag AUTH_AUTO_CREATE_ORG_ON_REGISTER=true preserves the legacy behavior that creates a community org + owner membership during registration; tests pin both modes in tests/api/auth/test_register.py and tests/api/auth/test_onboarding_foundation.py.
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 when present, and returns tokens. Verified orgless users are valid authenticated users; their token carries no org claim until they complete workspace creation or invite acceptance.
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 (optional)
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 when a verified non-superuser has no membership. The client must call GET /api/v1/auth/onboarding/state for the canonical next step instead of inferring state from login alone.
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¶
First-run onboarding is a server-routed sequence after identity verification. It separates workspace creation from integration setup and persists an explicit skip for users who choose to defer the first integration.
sequenceDiagram
participant C as Client
participant S as GET /onboarding/state
participant O as POST /onboard
participant G as GitHub App install
participant K as POST /onboarding/skip-integration
participant DB as PostgreSQL
participant A as AuthService
C->>S: Bearer orgless or org-scoped token
S->>DB: SELECT User + Membership + Organization + IntegrationCredential
alt verified user has no membership
S-->>C: OnboardingState {next_step: "workspace"}
C->>O: OnboardRequest {action: "create_org", org_name}
O->>DB: validate required workspace name
O->>DB: INSERT Organization
O->>DB: INSERT Membership (role="owner")
O->>DB: emit_audit_log(CREATE, ORGANIZATION)
O->>DB: COMMIT
O->>A: create_token_pair (new tokens with org_id)
O-->>C: 200 OnboardResponse {tokens, org_id, org_name, role}
C->>S: Bearer org-scoped token
end
alt workspace exists and no active integration
S-->>C: OnboardingState {next_step: "integration", recommended_provider: "github"}
C->>G: Start GitHub App install with return URL
G-->>C: Return-aware install completes or returns
else user defers integration
C->>K: Bearer org-scoped token
K->>DB: SET Organization.onboarding_integration_skipped_at
K-->>C: OnboardingState {next_step: "complete", integration_skipped: true}
else active integration exists or skip is persisted
S-->>C: OnboardingState {next_step: "complete"}
end
Route sequence:
POST /registercreates identity. WithAUTH_AUTO_CREATE_ORG_ON_REGISTER=false,org_idisnull.GET /verifymarks the identity verified.POST /loginreturns orgless tokens for verified users without memberships.GET /onboarding/statereturnsnext_step="workspace"for verified orgless non-superusers.POST /onboardwithaction="create_org"requiresorg_name, creates the workspace organization and owner membership, and returns org-scoped tokens.join_orgremains the invite path.GET /onboarding/statereturnsnext_step="integration"until the org has an active integration credential or a persisted skip.- The primary first integration is GitHub App installation. Return-aware install handling sends users back to the onboarding flow after installation.
POST /onboarding/skip-integrationrecordsOrganization.onboarding_integration_skipped_atand returns a state withnext_step="complete"andintegration_skipped=trueunless an integration is already connected.GET /api/v1/admin/setup/statuspowers admin setup gating after onboarding by distinguishing missing integration, missing sync config, failed sync, running sync, and ready states.
Completion: Non-admin users complete onboarding when a first integration is connected or the integration step is skipped. Superusers/admins route to dashboard in onboarding state and do not block on first-run setup.
Requires authentication: JWT bearer token in Authorization header. GET /onboarding/state accepts verified orgless tokens; POST /onboarding/skip-integration requires an org-scoped token and membership.
Migration behavior: Alembic 0026_add_org_onboarding_integration_skipped_at.py adds organizations.onboarding_integration_skipped_at for the C6 skip contract. Alembic 0027_make_refresh_tokens_org_id_nullable.py allows refresh-token records without org_id so orgless verified identities can maintain sessions before workspace creation.
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 |
/onboarding/state |
GET | Bearer | None | OnboardingStateResponse |
/onboarding/skip-integration |
POST | Bearer | None | OnboardingStateResponse |
/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} |
/api/v1/admin/setup/status |
GET | Org-scoped admin bearer | None | SetupStatusResponse |
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.
- Onboarding routing:
GET /onboarding/stateis the single backend source of truth for workspace/integration/complete/dashboard routing.