Skip to main content

max / multithreaded

10.9 KB · 183 lines History Blame Raw
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 | Variable | Required | Default | Example |
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 | Key | Type | Set When |
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 | Error Code | Cause |
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