# 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) | | `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 - Access tokens are **not stored** in the session or database. They are used once during the callback to fetch userinfo, then discarded. - No refresh token flow. When the session expires, the user must re-authenticate through MNW. - 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/deploy-hetzner.sh`, `deploy/deploy.sh` -- deploy scripts (production env vars are provisioned on-server, not committed to the repo)