# AWS Cognito Integration

This guide is for AWS operators and developers bootstrapping the Cognito-backed auth flow. All content is grounded in shipping code — file:line references point at the authoritative source, not aspirations.

Cognito is one of three supported auth providers (`local`, `agencio`, `cognito`). In production we default to Cognito; local is for tests and the dev-bypass path.

---

## 1. Environment variables

Defined in `packages/be/src/lib/config/env.ts`.

**Required to activate Cognito:**

| Variable | Purpose |
|---|---|
| `AUTH_PROVIDER=cognito` | Picks Cognito over local/agencio (defaults to `local`) |
| `COGNITO_USER_POOL_ID` | Your user-pool ID (e.g. `us-east-1_AbCdEfG`) |
| `COGNITO_CLIENT_ID` | App-client ID inside that pool |
| `AWS_REGION` | Pool's region — defaults to `us-east-1` if unset |

**AWS credentials — use one of these credential chains:**

1. Explicit env: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN`)
2. IAM role via IRSA (EKS): `AWS_ROLE_ARN` + `AWS_WEB_IDENTITY_TOKEN_FILE`
3. ECS task role: `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` (auto-detected)
4. EC2 instance profile (IMDSv2 — auto-detected)
5. Local dev: `AWS_PROFILE` + `~/.aws/credentials`

If none of these are set and `CognitoAuthProvider.isAvailable()` returns false, the provider factory logs a warning and falls back to local auth (`packages/be/src/lib/auth/providers/index.ts:45-68`).

**Optional — Secrets Manager for secrets instead of env:**

```bash
USE_SECRETS_MANAGER=true
AWS_SECRETS_REGION=us-east-1
SECRETS_PREFIX=agencio-predict/
```

---

## 2. User pool configuration

### Auth flows
Enable on the app client — **both required**:

- `USER_PASSWORD_AUTH` — used by `InitiateAuthCommand` for login (`cognito.ts:121`)
- `REFRESH_TOKEN_AUTH` — used by refresh-token flow

Leave `USER_SRP_AUTH` off unless you explicitly plan to use it; we don't.

### Sign-in / username attributes
- Users sign in with **email as username** (the SDK sends email as the `USERNAME` parameter)
- Either configure the pool with username = email, or enable email as an alias

### Email verification
- **Required.** `SignUpCommand` creates the user unverified; `ConfirmSignUpCommand` + a 6-digit code unlocks login
- Cognito sends the verification email itself (Cognito default templates or your SES config)

### MFA
- Set pool MFA to **Optional** or **Required**; `Off` breaks our per-user MFA-required toggle
- Enable **Authenticator app (TOTP)** in the pool — we call `AssociateSoftwareToken` / `VerifySoftwareToken`
- SMS MFA is supported but not required (`RespondToAuthChallengeCommand` handles `SMS_MFA` and `SOFTWARE_TOKEN_MFA`)

### Custom attributes (optional)
- `custom:role` — role string read from the ID token at `cognito.ts:452` (`admin`, `user`, etc.)
- `custom:organizationId` — org id claim

Not required — if absent we derive role from `cognito:groups` (membership in the `admin` group maps to role `admin`).

### Password policy
Whatever your pool requires, our Zod schemas enforce a minimum of 6 characters client-side (`packages/be/src/validation/auth.ts`). Cognito's own policy runs server-side regardless.

---

## 3. IAM permissions

The credentials the app runs under need these actions on the specific pool ARN:

```
cognito-idp:AdminCreateUser
cognito-idp:AdminDeleteUser
cognito-idp:AdminDisableUser
cognito-idp:AdminEnableUser
cognito-idp:AdminGetUser
cognito-idp:AdminResetUserPassword
cognito-idp:AdminSetUserPassword
cognito-idp:AdminSetUserMFAPreference
cognito-idp:AdminSetUserSettings
cognito-idp:AdminUpdateUserAttributes
cognito-idp:AssociateSoftwareToken
cognito-idp:ConfirmForgotPassword
cognito-idp:ConfirmSignUp
cognito-idp:DescribeUserPool
cognito-idp:DescribeUserPoolClient
cognito-idp:ForgotPassword
cognito-idp:GlobalSignOut
cognito-idp:InitiateAuth
cognito-idp:ListUsers
cognito-idp:ResendConfirmationCode
cognito-idp:RespondToAuthChallenge
cognito-idp:SetUserMFAPreference
cognito-idp:SignUp
cognito-idp:VerifySoftwareToken
```

Scope the policy to your pool's ARN — not `*` — e.g. `arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_AbCdEfG`.

---

## 4. Code map

### User-facing provider — `packages/be/src/lib/auth/providers/cognito.ts`

Implements the `AuthProvider` interface. Every method catches Cognito errors and maps them to stable app-level codes so the frontend can branch without parsing AWS error text.

| Method | AWS command | Responsibility |
|---|---|---|
| `authenticate()` | `InitiateAuthCommand` (USER_PASSWORD_AUTH) | Login; surfaces `MFA_REQUIRED` / `SMS_MFA` / `SOFTWARE_TOKEN_MFA` challenges instead of failing silently |
| `register()` | `SignUpCommand` | Self-service signup — Cognito emails verification code |
| `validateToken()` | JWKS verify via `jose` | Verifies Cognito-issued JWTs (ID + access) |
| `refreshToken()` | `InitiateAuthCommand` (REFRESH_TOKEN_AUTH) | New access token from refresh token |
| `confirmEmail()` | `ConfirmSignUpCommand` | Verify email, mirror to DB |
| `resendVerification()` | `ResendConfirmationCodeCommand` | Re-send code; enumeration-safe (swallows `UserNotFound`) |
| `respondToMfaChallenge()` | `RespondToAuthChallengeCommand` | Complete SMS/TOTP login challenge |
| `startMfaEnrollment()` | `AssociateSoftwareTokenCommand` | Returns TOTP secret + otpauth URL for QR render |
| `verifyMfaEnrollment()` | `VerifySoftwareTokenCommand` + `SetUserMFAPreferenceCommand` | Confirms code AND enables TOTP on the pool (the second call is what actually makes Cognito challenge at login) |
| `disableMfa()` | `SetUserMFAPreferenceCommand` | Blocked while `auth.users.mfa_required=true` |
| `requestPasswordReset()` | `ForgotPasswordCommand` | Start reset; enumeration-safe |
| `confirmPasswordReset()` | `ConfirmForgotPasswordCommand` | Confirm code + set new password |
| `signOut()` | `GlobalSignOutCommand` | Invalidate all tokens across devices |

### Admin operations — `packages/be/src/lib/auth/providers/cognito-admin.ts`

Called from `/admin/users` handlers. Every function returns a `CognitoAdminResult` discriminated union (`{ok: true, data}` | `{ok: false, reason, error?}`) so the handler can decide whether to surface the error to the admin.

- `adminCreateCognitoUser` / `adminDeleteCognitoUser`
- `adminEnableCognitoUser` / `adminDisableCognitoUser`
- `adminResetCognitoPassword` (user picks new password via Cognito email)
- `adminSetCognitoPassword` (force-set; optional `permanent` flag)
- `adminUpdateCognitoAttributes`
- `adminGetCognitoUser` / `adminListCognitoUsers`
- `adminSetCognitoMFAPreference` (per-user SMS/TOTP toggle)
- `adminRequireCognitoMFA` (force MFA on next login)
- `describeCognitoUserPool` / `describeCognitoUserPoolClient`

### AWS SDK client factory — `packages/be/src/lib/aws/client-factory.ts`

`getCognitoClient(region?)` returns a cached `CognitoIdentityProviderClient`. Credentials come from `explicitCredentials()`:
- If `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` are set, pass them to the client
- Otherwise return `undefined` and let the SDK walk the default credential chain (IRSA / ECS role / EC2 profile / shared file)

Clients are module-level singletons. Call `resetAwsClients()` for tests that need a fresh instance.

### Provider selection — `packages/be/src/lib/auth/providers/index.ts`

`getConfiguredProviderType()` reads `AUTH_PROVIDER`; `getAuthProvider()` returns the configured provider, falling back to local with a warning if the configured one fails `isAvailable()`. `getActiveProviderType()` returns the provider actually in use.

---

## 5. Onboarding state ↔ Cognito

Our 8-state onboarding ladder (`@agencio-predict/shared`: `active | suspended | disabled | pending_verification | pending_approval | pending_mfa | pending_subscription | rejected`) lives on `auth.users.status`. Cognito drives four of the transitions by mirroring into the local DB right after a successful SDK command.

```sql
-- confirmEmail (cognito.ts:447-454)
UPDATE auth.users
   SET email_verified = TRUE,
       status = CASE WHEN status = 'pending_verification'
                THEN 'pending_approval' ELSE status END,
       updated_at = NOW()
 WHERE email = $1 AND auth_provider = 'cognito';

-- verifyMfaEnrollment (cognito.ts:550-558)
UPDATE auth.users
   SET mfa_method = 'totp',
       mfa_enrolled_at = NOW(),
       status = CASE WHEN status = 'pending_mfa'
                THEN 'pending_subscription' ELSE status END,
       updated_at = NOW()
 WHERE id = $1;

-- disableMfa
UPDATE auth.users
   SET mfa_method = NULL,
       mfa_enrolled_at = NULL,
       updated_at = NOW()
 WHERE id = $1;

-- confirmPasswordReset
UPDATE auth.users
   SET password_reset_at = NOW(), updated_at = NOW()
 WHERE email = $1 AND auth_provider = 'cognito';
```

The remaining transitions (`pending_approval → pending_mfa`, `pending_subscription → active`) are driven by:
- Admin approval via `POST /admin/users/:id/approve` — doesn't touch Cognito
- Stripe webhook `subscription.created` → `activateUserOnSubscription()`

---

## 6. Admin UI

- **Page:** `/admin/cognito` (`apps/web/src/app/admin/cognito/page.tsx`) — shows env-var status, pool config snapshot, app-client config, 20 most recent users, DB sync count, MFA mode, password policy
- **Test probe:** `POST /api/predict/v1/admin/cognito/test` — calls `describeCognitoUserPool()` + `adminListCognitoUsers(1)` to verify credentials round-trip

Both admin-gated via `requireAuth()` + `isAdminRole()`.

---

## 7. Local development

### Option A — Run against local provider (default)
Fastest iteration for everything except Cognito-specific flows.

```bash
# .env.local or docker-compose.dev.yml:
AUTH_PROVIDER=local            # or omit, defaults to local
ALLOW_DEV_AUTH_BYPASS=true     # optional: DEV_USER with admin role
```

Email verification + MFA + password reset all run app-side (`lib/auth/email-verification.ts`, `lib/auth/mfa-enrollment.ts`, `lib/auth/password-reset.ts`) using Azure Graph / O365 for email.

### Option B — Real Cognito pool from dev
Required when developing Cognito-specific behavior (challenge flows, email templates, admin-MFA actions).

1. Create a Cognito user pool + app client in AWS (free tier is plenty)
2. Configure the pool per **Section 2** above
3. Attach the IAM policy from **Section 3** to your dev-only IAM user
4. Set env vars in `docker-compose.dev.yml` under `predict-web.environment`:
   ```yaml
   AUTH_PROVIDER: cognito
   COGNITO_USER_POOL_ID: us-east-1_AbCdEfG
   COGNITO_CLIENT_ID: 1234567890abcdefghij
   AWS_REGION: us-east-1
   AWS_ACCESS_KEY_ID: AKIA...
   AWS_SECRET_ACCESS_KEY: ...
   ```
5. Restart: `docker compose -f docker-compose.dev.yml up -d --no-deps predict-web`
6. Visit `/admin/cognito` → click **Test connection** to verify credentials + permissions

### Dev auth bypass safety
`ALLOW_DEV_AUTH_BYPASS=true` grants a mock admin `DEV_USER` when no auth header is sent. Gated by `NODE_ENV !== 'production'` + the flag (`packages/be/src/lib/auth/middleware.ts`). **Never** set this in a deployed environment — it grants full admin access unauthenticated.

---

## 8. Troubleshooting

| Symptom | Likely cause |
|---|---|
| `CognitoAuthProvider isAvailable=false, falling back to local` in logs | One of `COGNITO_USER_POOL_ID` / `COGNITO_CLIENT_ID` missing |
| `USER_PASSWORD_AUTH not enabled` on login | App client doesn't have the flow enabled — fix in AWS console |
| 401 `AUTH_FAILED` on correct credentials | Email not yet verified; `ConfirmSignUpCommand` must run first |
| Login silently fails for MFA-enabled users | You're on an old build; Phase 2 (MFA challenge handler) added this — confirm `respondToMfaChallenge` is wired |
| `User does not exist` on reset | Cognito returns `UserNotFoundException` — we swallow it (enumeration-safe); always returns success |
| TOTP code accepted but no challenge at login | You didn't call `SetUserMFAPreferenceCommand` after verify — check `verifyMfaEnrollment` runs through both calls |
| `/admin/cognito/test` fails with `AccessDeniedException` | IAM policy missing the action the test calls — usually `DescribeUserPool` or `ListUsers` |
| Admin-created users can't log in | Cognito sets status `FORCE_CHANGE_PASSWORD` on admin-created users; either use `adminSetCognitoPassword(..., permanent=true)` or send them through the reset-password flow |

---

## 9. Related docs

- Onboarding state machine + phases 1-8: `TODO.md` § Onboarding state machine
- Azure Graph email (used alongside Cognito for local/agencio verification): `packages/be/src/email/azure-graph.ts`
- Admin user handlers: `packages/be/src/api/predict/v1/admin/users/handlers.ts`
- Shared auth types + onboarding helpers: `packages/shared/src/auth.ts`
