| 1 |
# OAuth Flow |
| 2 |
|
| 3 |
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. |
| 4 |
|
| 5 |
## Environment Variables |
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
| `MNW_BASE_URL` | Yes | `http://127.0.0.1:3000` | `https://makenot.work` | |
| 10 |
| `OAUTH_CLIENT_ID` | Yes | -- | `mt-forums-6378957b452bbbc906c3db8edd072d64` | |
| 11 |
| `OAUTH_REDIRECT_URI` | Yes | `http://127.0.0.1:3400/auth/callback` | `https://forums.makenot.work/auth/callback` | |
| 12 |
| `COOKIE_SECURE` | No | `true` | `false` (local dev over HTTP) | |
| 13 |
| `PLATFORM_ADMIN_ID` | No | -- | UUID of the platform admin user | |
| 14 |
|
| 15 |
## PKCE OAuth Flow (Step by Step) |
| 16 |
|
| 17 |
``` |
| 18 |
Browser Multithreaded MNW (OAuth Provider) |
| 19 |
│ │ │ |
| 20 |
│ 1. GET /auth/login │ │ |
| 21 |
│──────────────────────>│ │ |
| 22 |
│ │ 2. Generate verifier, │ |
| 23 |
│ │ challenge, state │ |
| 24 |
│ │ Store in session │ |
| 25 |
│ │ │ |
| 26 |
│ 3. 302 Redirect │ │ |
| 27 |
│<──────────────────────│ │ |
| 28 |
│ │ |
| 29 |
│ 4. GET /oauth/authorize?response_type=code │ |
| 30 |
│ &client_id=...&redirect_uri=... │ |
| 31 |
│ &state=...&code_challenge=... │ |
| 32 |
│ &code_challenge_method=S256 │ |
| 33 |
│──────────────────────────────────────────────────>│ |
| 34 |
│ │ |
| 35 |
│ 5. User authenticates on MNW │ |
| 36 |
│<─────────────────────────────────────────────────>│ |
| 37 |
│ │ |
| 38 |
│ 6. 302 Redirect to redirect_uri │ |
| 39 |
│ ?code=...&state=... │ |
| 40 |
│<──────────────────────────────────────────────────│ |
| 41 |
│ │ |
| 42 |
│ 7. GET /auth/callback │ |
| 43 |
│ ?code=...&state=... │ |
| 44 |
│──────────────────────>│ │ |
| 45 |
│ │ 8. Verify state nonce │ |
| 46 |
│ │ Retrieve verifier │ |
| 47 |
│ │ Clean up OAuth data │ |
| 48 |
│ │ │ |
| 49 |
│ │ 9. POST /oauth/token │ |
| 50 |
│ │ {grant_type, code, │ |
| 51 |
│ │ redirect_uri, │ |
| 52 |
│ │ code_verifier, │ |
| 53 |
│ │ client_id} │ |
| 54 |
│ │──────────────────────────>│ |
| 55 |
│ │ │ |
| 56 |
│ │ 10. {access_token: ...} │ |
| 57 |
│ │<──────────────────────────│ |
| 58 |
│ │ │ |
| 59 |
│ │ 11. GET /oauth/userinfo │ |
| 60 |
│ │ Authorization: │ |
| 61 |
│ │ Bearer <token> │ |
| 62 |
│ │──────────────────────────>│ |
| 63 |
│ │ │ |
| 64 |
│ │ 12. {user_id, username, │ |
| 65 |
│ │ display_name, │ |
| 66 |
│ │ avatar_url} │ |
| 67 |
│ │<──────────────────────────│ |
| 68 |
│ │ │ |
| 69 |
│ │ 13. Upsert local user │ |
| 70 |
│ │ Check suspension │ |
| 71 |
│ │ Save session │ |
| 72 |
│ │ Cycle session ID │ |
| 73 |
│ │ │ |
| 74 |
│ 14. 302 Redirect / │ │ |
| 75 |
│<──────────────────────│ │ |
| 76 |
``` |
| 77 |
|
| 78 |
### Detailed Steps |
| 79 |
|
| 80 |
1. **User clicks "Log in"** -- browser sends `GET /auth/login`. |
| 81 |
2. **Generate PKCE material** -- 32-byte random verifier (base64url), SHA-256 challenge (base64url), 16-byte state nonce (hex). Verifier and state stored in session. |
| 82 |
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`. |
| 83 |
4. **MNW authorize endpoint** -- MNW shows its login/consent UI. |
| 84 |
5. **User authenticates** -- enters credentials on MNW (or is already logged in). |
| 85 |
6. **MNW redirects back** -- 302 to `redirect_uri` with `code` and `state` query params. |
| 86 |
7. **Browser follows redirect** -- `GET /auth/callback?code=...&state=...`. |
| 87 |
8. **Validate state and retrieve verifier** -- compare `state` param against session value (reject on mismatch). Retrieve PKCE verifier from session. Remove both from session. |
| 88 |
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). |
| 89 |
10. **Receive access token** -- MNW responds with `{ access_token: "..." }`. |
| 90 |
11. **Fetch user info** -- `GET {MNW_BASE_URL}/oauth/userinfo` with `Authorization: Bearer {access_token}`. |
| 91 |
12. **Receive user profile** -- `{ user_id, username, display_name, avatar_url }`. |
| 92 |
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. |
| 93 |
14. **Redirect home** -- 302 to `/`. |
| 94 |
|
| 95 |
## Session Management |
| 96 |
|
| 97 |
- **Store**: `tower-sessions` with `PostgresStore` (auto-migrated table). |
| 98 |
- **Cookie name**: `mt_session`. |
| 99 |
- **Expiry**: 7 days of inactivity (`OnInactivity`). Reset on each request. |
| 100 |
- **SameSite**: `Lax` (allows top-level navigations but blocks cross-origin POST). |
| 101 |
- **Secure flag**: controlled by `COOKIE_SECURE` env var. `true` in production (HTTPS), `false` for local HTTP dev. |
| 102 |
- **Expired session cleanup**: background tokio task runs every 3600 seconds, deletes expired rows from the session table. |
| 103 |
- **Session ID cycling**: on successful login, `session.cycle_id()` generates a new session ID to prevent session fixation attacks. |
| 104 |
|
| 105 |
### Session Keys |
| 106 |
|
| 107 |
|
| 108 |
|
| 109 |
| `user_id` | `Uuid` | Login success | |
| 110 |
| `username` | `String` | Login success | |
| 111 |
| `display_name` | `Option<String>` | Login success | |
| 112 |
| `oauth_state` | `String` | Login initiated (cleared after callback) | |
| 113 |
| `pkce_verifier` | `String` | Login initiated (cleared after callback) | |
| 114 |
| `csrf_token` | `String` | First state-changing request or template render | |
| 115 |
|
| 116 |
## Auth Extractors |
| 117 |
|
| 118 |
### `MaybeUser(Option<SessionUser>)` |
| 119 |
|
| 120 |
- Infallible extractor. Always succeeds. |
| 121 |
- Returns `Some(SessionUser)` if the session contains valid user data, `None` otherwise. |
| 122 |
- Used by public routes that show different UI for logged-in vs anonymous users. |
| 123 |
|
| 124 |
### `PlatformAdmin(SessionUser)` |
| 125 |
|
| 126 |
- Fallible extractor. Returns `404 Not Found` if the user is not logged in or is not the platform admin. |
| 127 |
- Compares `session.user_id` against `config.platform_admin_id`. |
| 128 |
- Returns 404 (not 403) to hide admin routes from non-admins. |
| 129 |
- Used by `/_admin/*` routes. |
| 130 |
|
| 131 |
### `SessionUser` |
| 132 |
|
| 133 |
- Not an extractor itself. Data struct stored in sessions. |
| 134 |
- Fields: `user_id: Uuid`, `username: String`, `display_name: Option<String>`. |
| 135 |
|
| 136 |
## Token Handling |
| 137 |
|
| 138 |
- Access tokens are **not stored** in the session or database. They are used once during the callback to fetch userinfo, then discarded. |
| 139 |
- No refresh token flow. When the session expires, the user must re-authenticate through MNW. |
| 140 |
- The PKCE verifier is ephemeral -- generated at login initiation, consumed at callback, never persisted beyond the session. |
| 141 |
|
| 142 |
## CSRF Protection |
| 143 |
|
| 144 |
Separate from OAuth but relevant to authenticated requests: |
| 145 |
|
| 146 |
- SHA-256 random token (32 bytes, 64 hex chars) generated per session. |
| 147 |
- Stored in session under `csrf_token` key. |
| 148 |
- Included in HTML via meta tag, sent on POST/PUT/PATCH/DELETE via `X-CSRF-Token` header. |
| 149 |
- Validated by `csrf_middleware` with constant-time comparison. |
| 150 |
- Exempt paths: `/auth/*`, `/api/health`, `/_test/*`. |
| 151 |
|
| 152 |
## Error Codes |
| 153 |
|
| 154 |
All OAuth errors redirect to `/?error={code}`. The error code is a query parameter on the home page. |
| 155 |
|
| 156 |
|
| 157 |
|
| 158 |
| `state_mismatch` | `state` param does not match session value. Possible CSRF or expired session. | |
| 159 |
| `missing_verifier` | PKCE verifier not found in session. Session expired between login click and callback. | |
| 160 |
| `token_request_failed` | Network error contacting MNW `/oauth/token`. | |
| 161 |
| `token_exchange_failed` | MNW returned non-2xx from `/oauth/token`. Invalid code, expired code, or bad verifier. | |
| 162 |
| `token_parse_failed` | MNW token response was not valid JSON or missing `access_token`. | |
| 163 |
| `userinfo_request_failed` | Network error contacting MNW `/oauth/userinfo`. | |
| 164 |
| `userinfo_fetch_failed` | MNW returned non-2xx from `/oauth/userinfo`. Token invalid or expired. | |
| 165 |
| `userinfo_parse_failed` | Userinfo response was not valid JSON or missing expected fields. | |
| 166 |
| `user_upsert_failed` | Database error inserting/updating local user record. | |
| 167 |
| `internal_error` | Database error checking suspension status (fail-closed). | |
| 168 |
| `account_suspended` | User's local account has `suspended_at` set. Login blocked. | |
| 169 |
|
| 170 |
## Logout |
| 171 |
|
| 172 |
`POST /auth/logout` -- flushes the entire session (removes all keys, deletes session row) and redirects to `/`. |
| 173 |
|
| 174 |
## Key Paths |
| 175 |
|
| 176 |
- `src/auth.rs` -- PKCE helpers, session user, login/callback/logout handlers |
| 177 |
- `src/config.rs` -- `Config::from_env()`, all OAuth-related env vars |
| 178 |
- `src/csrf.rs` -- CSRF token generation, middleware, constant-time comparison |
| 179 |
- `src/internal_auth.rs` -- HMAC-SHA256 auth for MNW-to-MT internal API (separate from OAuth) |
| 180 |
- `src/main.rs` -- session store setup, session layer config, middleware stack |
| 181 |
- `deploy/env.hetzner` -- production env vars (hetzner) |
| 182 |
- `deploy/env.production` -- production env vars (astra) |
| 183 |
|