# OAuth Flow Multithreaded uses MNW as its sole identity provider. All authentication goes through an OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange). No passwords are stored locally. ## Environment Variables | Variable | Required | Default | Example | |----------|----------|---------|---------| | `MNW_BASE_URL` | Yes | `http://127.0.0.1:3000` | `https://makenot.work` | | `OAUTH_CLIENT_ID` | Yes | -- | `mt-forums-6378957b452bbbc906c3db8edd072d64` | | `OAUTH_REDIRECT_URI` | Yes | `http://127.0.0.1:3400/auth/callback` | `https://forums.makenot.work/auth/callback` | | `COOKIE_SECURE` | No | `true` | `false` (local dev over HTTP) | | `PLATFORM_ADMIN_ID` | No | -- | UUID of the platform admin user | ## PKCE OAuth Flow (Step by Step) ``` Browser Multithreaded MNW (OAuth Provider) │ │ │ │ 1. GET /auth/login │ │ │──────────────────────>│ │ │ │ 2. Generate verifier, │ │ │ challenge, state │ │ │ Store in session │ │ │ │ │ 3. 302 Redirect │ │ │<──────────────────────│ │ │ │ │ 4. GET /oauth/authorize?response_type=code │ │ &client_id=...&redirect_uri=... │ │ &state=...&code_challenge=... │ │ &code_challenge_method=S256 │ │──────────────────────────────────────────────────>│ │ │ │ 5. User authenticates on MNW │ │<─────────────────────────────────────────────────>│ │ │ │ 6. 302 Redirect to redirect_uri │ │ ?code=...&state=... │ │<──────────────────────────────────────────────────│ │ │ │ 7. GET /auth/callback │ │ ?code=...&state=... │ │──────────────────────>│ │ │ │ 8. Verify state nonce │ │ │ Retrieve verifier │ │ │ Clean up OAuth data │ │ │ │ │ │ 9. POST /oauth/token │ │ │ {grant_type, code, │ │ │ redirect_uri, │ │ │ code_verifier, │ │ │ client_id} │ │ │──────────────────────────>│ │ │ │ │ │ 10. {access_token: ...} │ │ │<──────────────────────────│ │ │ │ │ │ 11. GET /oauth/userinfo │ │ │ Authorization: │ │ │ Bearer │ │ │──────────────────────────>│ │ │ │ │ │ 12. {user_id, username, │ │ │ display_name, │ │ │ avatar_url} │ │ │<──────────────────────────│ │ │ │ │ │ 13. Upsert local user │ │ │ Check suspension │ │ │ Save session │ │ │ Cycle session ID │ │ │ │ │ 14. 302 Redirect / │ │ │<──────────────────────│ │ ``` ### Detailed Steps 1. **User clicks "Log in"**: browser sends `GET /auth/login`. 2. **Generate PKCE material**: 32-byte random verifier (base64url), SHA-256 challenge (base64url), 16-byte state nonce (hex). Verifier and state stored in session. 3. **Redirect to MNW**: 302 to `{MNW_BASE_URL}/oauth/authorize` with query params: `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`. 4. **MNW authorize endpoint**: MNW shows its login/consent UI. 5. **User authenticates**: enters credentials on MNW (or is already logged in). 6. **MNW redirects back**: 302 to `redirect_uri` with `code` and `state` query params. 7. **Browser follows redirect**: `GET /auth/callback?code=...&state=...`. 8. **Validate state and retrieve verifier**: compare `state` param against session value (reject on mismatch). Retrieve PKCE verifier from session. Remove both from session. 9. **Token exchange**: `POST {MNW_BASE_URL}/oauth/token` with JSON body: `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`, `client_id`. No client_secret (PKCE replaces it). 10. **Receive access token**: MNW responds with `{ access_token: "..." }`. 11. **Fetch user info**: `GET {MNW_BASE_URL}/oauth/userinfo` with `Authorization: Bearer {access_token}`. 12. **Receive user profile**: `{ user_id, username, display_name, avatar_url }`. 13. **Local processing**: upsert user into `users` table (keyed on `mnw_account_id`), check `suspended_at` (fail-closed: DB errors block login), save `SessionUser` (user_id, username, display_name) to session, cycle session ID to prevent fixation. 14. **Redirect home**: 302 to `/`. ## Session Management - **Store**: `tower-sessions` with `PostgresStore` (auto-migrated table). - **Cookie name**: `mt_session`. - **Expiry**: 7 days of inactivity (`OnInactivity`). Reset on each request. - **SameSite**: `Lax` (allows top-level navigations but blocks cross-origin POST). - **Secure flag**: controlled by `COOKIE_SECURE` env var. `true` in production (HTTPS), `false` for local HTTP dev. - **Expired session cleanup**: background tokio task runs every 3600 seconds, deletes expired rows from the session table. - **Session ID cycling**: on successful login, `session.cycle_id()` generates a new session ID to prevent session fixation attacks. ### Session Keys | Key | Type | Set When | |-----|------|----------| | `user_id` | `Uuid` | Login success | | `username` | `String` | Login success | | `display_name` | `Option` | Login success | | `oauth_state` | `String` | Login initiated (cleared after callback) | | `pkce_verifier` | `String` | Login initiated (cleared after callback) | | `mnw_refresh_token` | `String` | Login success; rotated on each `/auth/refresh` | | `csrf_token` | `String` | First state-changing request or template render | ## Auth Extractors ### `MaybeUser(Option)` - Infallible extractor. Always succeeds. - Returns `Some(SessionUser)` if the session contains valid user data, `None` otherwise. - Used by public routes that show different UI for logged-in vs anonymous users. ### `PlatformAdmin(SessionUser)` - Fallible extractor. Returns `404 Not Found` if the user is not logged in or is not the platform admin. - Compares `session.user_id` against `config.platform_admin_id`. - Returns 404 (not 403) to hide admin routes from non-admins. - Used by `/_admin/*` routes. ### `SessionUser` - Not an extractor itself. Data struct stored in sessions. - Fields: `user_id: Uuid`, `username: String`, `display_name: Option`. ## Token Handling MT is the reference relying party for MNW's OAuth provider, dogfooding both the back-channel refresh-token flow (default) and the front-channel `prompt=none` silent re-auth. The governing principle: **the MT session governs login longevity; the MNW token governs only perk freshness.** Removing the stored token therefore never forces a re-login. - **Access tokens are never stored.** Login requests `scope=profile:read perks:read offline_access`; MNW returns a short-lived (≈5 min) *userinfo-scoped* access token plus a rotating refresh token. The access token is used once at callback to fetch userinfo, then discarded. It carries a userinfo audience, so even if captured it cannot act on MNW's sync API (closes finding S13). - **Refresh tokens are stored and rotated.** Only the scoped refresh token is persisted (`mnw_refresh_token`). `POST /auth/refresh` trades it via `grant_type=refresh_token` for a fresh access token **and a new refresh token** (rotation: the prior token is invalidated; reuse is theft-detectable on the MNW side). The new refresh token replaces the stored one. - **A dead refresh token does not log the user out.** If refresh returns `invalid_grant` (expired/rotated/revoked), MT clears the stored token and returns `401` from `/auth/refresh` **without** flushing the session. The user stays logged in with last-known perks and can re-link their MNW account; the session's own 7-day inactivity expiry governs logout. - **`prompt=none` silent re-auth (`GET /auth/reverify`)** is the zero-credential-at-rest alternative third-party RPs can choose: MT bounces the browser through `/oauth/authorize?prompt=none` (no `offline_access`, so no refresh token is issued), uses the returned short-lived token for one userinfo fetch, and stores nothing. If the MNW session has lapsed, MNW redirects back with `error=login_required` and MT keeps the last-known perks. - The PKCE verifier is ephemeral: generated at login initiation, consumed at callback, never persisted beyond the session. ## CSRF Protection Separate from OAuth but relevant to authenticated requests: - SHA-256 random token (32 bytes, 64 hex chars) generated per session. - Stored in session under `csrf_token` key. - Included in HTML via meta tag, sent on POST/PUT/PATCH/DELETE via `X-CSRF-Token` header. - Validated by `csrf_middleware` with constant-time comparison. - Exempt paths: `/auth/*`, `/api/health`, `/_test/*`. ## Error Codes All OAuth errors redirect to `/?error={code}`. The error code is a query parameter on the home page. | Error Code | Cause | |------------|-------| | `state_mismatch` | `state` param does not match session value. Possible CSRF or expired session. | | `missing_verifier` | PKCE verifier not found in session. Session expired between login click and callback. | | `token_request_failed` | Network error contacting MNW `/oauth/token`. | | `token_exchange_failed` | MNW returned non-2xx from `/oauth/token`. Invalid code, expired code, or bad verifier. | | `token_parse_failed` | MNW token response was not valid JSON or missing `access_token`. | | `userinfo_request_failed` | Network error contacting MNW `/oauth/userinfo`. | | `userinfo_fetch_failed` | MNW returned non-2xx from `/oauth/userinfo`. Token invalid or expired. | | `userinfo_parse_failed` | Userinfo response was not valid JSON or missing expected fields. | | `user_upsert_failed` | Database error inserting/updating local user record. | | `internal_error` | Database error checking suspension status (fail-closed). | | `account_suspended` | User's local account has `suspended_at` set. Login blocked. | ## Logout `POST /auth/logout` flushes the entire session (removes all keys, deletes session row) and redirects to `/`. ## Key Paths - `src/auth.rs`: PKCE helpers, session user, login/callback/logout handlers - `src/config.rs`: `Config::from_env()`, all OAuth-related env vars - `src/csrf.rs`: CSRF token generation, middleware, constant-time comparison - `src/internal_auth.rs`: HMAC-SHA256 auth for MNW-to-MT internal API (separate from OAuth) - `src/main.rs`: session store setup, session layer config, middleware stack - `deploy/README.md`, `deploy/deploy.sh`: deploy procedure (production env vars are provisioned on-server, not committed to the repo)