Skip to main content

max / makenotwork

33.6 KB · 878 lines History Blame Raw
1 //! OAuth client for "Log in with Makenot.work" and session user extraction.
2 //!
3 //! Perks (Fan+, creator tier, capabilities) come from MNW's `/oauth/userinfo`
4 //! `perks` object. We cache them in the session and refresh on three triggers:
5 //! (1) login, (2) session cycle, (3) on-demand via `POST /auth/refresh`. See
6 //! `MNW/server/docs/oauth_integration.md` for the contract.
7
8 use axum::{
9 Json,
10 extract::{FromRequestParts, Query, State},
11 http::{StatusCode, request::Parts},
12 response::{IntoResponse, Redirect},
13 };
14 use base64::Engine;
15 use rand::Rng;
16 use serde::{Deserialize, Serialize};
17 use sha2::{Digest, Sha256};
18 use tokio::time::sleep;
19 use tower_sessions::Session;
20
21 use crate::AppState;
22
23 // --- PKCE helpers
24
25 fn generate_verifier() -> String {
26 let mut bytes = [0u8; 32];
27 rand::rng().fill_bytes(&mut bytes);
28 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
29 }
30
31 fn pkce_challenge(verifier: &str) -> String {
32 let mut hasher = Sha256::new();
33 hasher.update(verifier.as_bytes());
34 let digest = hasher.finalize();
35 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
36 }
37
38 fn generate_state_nonce() -> String {
39 let mut bytes = [0u8; 16];
40 rand::rng().fill_bytes(&mut bytes);
41 hex::encode(bytes)
42 }
43
44 // --- session user
45
46 /// User info cached in the session after OAuth login.
47 ///
48 /// `perks` reflects MNW state at the last refresh (login, session cycle, or
49 /// explicit `POST /auth/refresh`). Use [`UserPerks::effective_plus`] for the
50 /// canonical Fan+ gate.
51 #[derive(Clone, Debug)]
52 pub struct SessionUser {
53 pub user_id: uuid::Uuid,
54 pub username: String,
55 pub display_name: Option<String>,
56 pub perks: UserPerks,
57 }
58
59 /// Capability snapshot from MNW's `/oauth/userinfo` `perks` object.
60 ///
61 /// Default = no perks; this is what unknown / not-yet-refreshed sessions see.
62 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
63 pub struct UserPerks {
64 #[serde(default)]
65 pub fan_plus: bool,
66 #[serde(default)]
67 pub is_creator: bool,
68 #[serde(default)]
69 pub creator_tier: Option<CreatorTierInfo>,
70 }
71
72 #[derive(Clone, Debug, Serialize, Deserialize)]
73 pub struct CreatorTierInfo {
74 pub tier: String,
75 pub features: Vec<String>,
76 }
77
78 impl UserPerks {
79 /// Canonical "should this user see + features" check. True for active Fan+
80 /// subscribers and for any creator (auto-grant: creators get + perks without
81 /// paying for Fan+ separately).
82 pub fn effective_plus(&self) -> bool {
83 self.fan_plus || self.is_creator
84 }
85 }
86
87 const SESSION_USER_ID: &str = "user_id";
88 const SESSION_USERNAME: &str = "username";
89 const SESSION_DISPLAY_NAME: &str = "display_name";
90 const SESSION_PERKS: &str = "perks";
91 /// The MNW **refresh** token, scoped (`perks:read`/`profile:read`), rotating,
92 /// and unable to act as the user on the sync API. This is the only MNW
93 /// credential stored at rest (closing finding S13); the short-lived access
94 /// token is used transiently for one userinfo fetch and never persisted.
95 const SESSION_REFRESH_TOKEN: &str = "mnw_refresh_token";
96 const SESSION_OAUTH_STATE: &str = "oauth_state";
97 const SESSION_PKCE_VERIFIER: &str = "pkce_verifier";
98
99 /// Per-request timeout for the outbound OAuth calls (userinfo, token refresh).
100 /// Tighter than the shared client's 15s total and the outer 30s TimeoutLayer, so
101 /// a stalled MNW OAuth endpoint fails the login/refresh fast instead of parking.
102 const OAUTH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
103
104 impl SessionUser {
105 async fn from_session(session: &Session) -> Option<Self> {
106 let user_id: uuid::Uuid = match session.get(SESSION_USER_ID).await {
107 Ok(v) => v?,
108 Err(e) => {
109 tracing::warn!(error = %e, "failed to read user_id from session");
110 return None;
111 }
112 };
113 let username: String = match session.get(SESSION_USERNAME).await {
114 Ok(v) => v?,
115 Err(e) => {
116 tracing::warn!(error = %e, "failed to read username from session");
117 return None;
118 }
119 };
120 let display_name: Option<String> = match session.get(SESSION_DISPLAY_NAME).await {
121 Ok(v) => v,
122 Err(e) => {
123 tracing::warn!(error = %e, "failed to read display_name from session");
124 None
125 }
126 };
127 // Perks default to empty, sessions predating the perks change still load.
128 let perks: UserPerks = session
129 .get(SESSION_PERKS)
130 .await
131 .unwrap_or_default()
132 .unwrap_or_default();
133 Some(Self {
134 user_id,
135 username,
136 display_name,
137 perks,
138 })
139 }
140
141 async fn save_to_session(&self, session: &Session) {
142 if let Err(e) = session.insert(SESSION_USER_ID, self.user_id).await {
143 tracing::error!(error = %e, "failed to save user_id to session");
144 }
145 if let Err(e) = session.insert(SESSION_USERNAME, &self.username).await {
146 tracing::error!(error = %e, "failed to save username to session");
147 }
148 if let Err(e) = session
149 .insert(SESSION_DISPLAY_NAME, &self.display_name)
150 .await
151 {
152 tracing::error!(error = %e, "failed to save display_name to session");
153 }
154 if let Err(e) = session.insert(SESSION_PERKS, &self.perks).await {
155 tracing::error!(error = %e, "failed to save perks to session");
156 }
157 }
158 }
159
160 /// Axum extractor that yields `Option<SessionUser>`.
161 pub struct MaybeUser(pub Option<SessionUser>);
162
163 impl FromRequestParts<AppState> for MaybeUser {
164 type Rejection = std::convert::Infallible;
165
166 async fn from_request_parts(
167 parts: &mut Parts,
168 state: &AppState,
169 ) -> Result<Self, Self::Rejection> {
170 let session = Session::from_request_parts(parts, state)
171 .await
172 .expect("session layer missing");
173 Ok(MaybeUser(SessionUser::from_session(&session).await))
174 }
175 }
176
177 /// Axum extractor that requires an authenticated session.
178 ///
179 /// Yields the [`SessionUser`] directly, or rejects with a redirect to
180 /// `/auth/login`, the exact behaviour ~30 write/settings handlers previously
181 /// open-coded as `session_user.ok_or_else(|| Redirect::to("/auth/login")…)?`.
182 /// Use this instead of `MaybeUser` whenever the handler needs a logged-in user.
183 pub struct RequireUser(pub SessionUser);
184
185 impl FromRequestParts<AppState> for RequireUser {
186 type Rejection = axum::response::Response;
187
188 async fn from_request_parts(
189 parts: &mut Parts,
190 state: &AppState,
191 ) -> Result<Self, Self::Rejection> {
192 let session = Session::from_request_parts(parts, state)
193 .await
194 .expect("session layer missing");
195 let user = SessionUser::from_session(&session)
196 .await
197 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
198 Ok(RequireUser(user))
199 }
200 }
201
202 /// Axum extractor that requires the user to be the platform admin.
203 /// Returns 404 to non-admins (hides admin routes).
204 pub struct PlatformAdmin(pub SessionUser);
205
206 impl FromRequestParts<AppState> for PlatformAdmin {
207 type Rejection = StatusCode;
208
209 async fn from_request_parts(
210 parts: &mut Parts,
211 state: &AppState,
212 ) -> Result<Self, Self::Rejection> {
213 let session = Session::from_request_parts(parts, state)
214 .await
215 .expect("session layer missing");
216 let user = SessionUser::from_session(&session)
217 .await
218 .ok_or(StatusCode::NOT_FOUND)?;
219
220 let admin_id = state
221 .config
222 .platform_admin_id
223 .ok_or(StatusCode::NOT_FOUND)?;
224 if user.user_id != admin_id {
225 return Err(StatusCode::NOT_FOUND);
226 }
227
228 Ok(PlatformAdmin(user))
229 }
230 }
231
232 // --- OAuth callback types
233
234 #[derive(Deserialize)]
235 pub struct CallbackQuery {
236 /// Absent on a `prompt=none` failure, where the provider returns `error`.
237 #[serde(default)]
238 pub code: Option<String>,
239 pub state: String,
240 /// OIDC error (e.g. `login_required`) from a `prompt=none` silent attempt.
241 #[serde(default)]
242 pub error: Option<String>,
243 }
244
245 #[derive(Deserialize)]
246 struct TokenResponse {
247 access_token: String,
248 /// Present when the grant included `offline_access`. `Option` so a provider
249 /// that declines it (or hasn't shipped refresh tokens) still parses.
250 #[serde(default)]
251 refresh_token: Option<String>,
252 /// Informational only, login longevity is governed by the MT session, not
253 /// the access token.
254 #[serde(default)]
255 #[allow(dead_code)]
256 expires_in: Option<i64>,
257 }
258
259 #[derive(Deserialize)]
260 struct UserinfoResponse {
261 user_id: uuid::Uuid,
262 username: String,
263 display_name: Option<String>,
264 avatar_url: Option<String>,
265 #[serde(default)]
266 perks: UserPerks,
267 }
268
269 #[derive(Debug)]
270 pub enum UserinfoError {
271 Unauthorized,
272 Transport,
273 BadResponse,
274 /// No usable refresh token: none stored, or the stored one was expired /
275 /// rotated / revoked (`invalid_grant`). The MT session is NOT torn down,
276 /// the user stays logged in with last-known perks and can re-link MNW. This
277 /// is distinct from `Unauthorized`, which historically flushed the session.
278 RefreshUnavailable,
279 }
280
281 /// Single-attempt userinfo fetch against MNW. Callers decide retry policy.
282 ///
283 /// `Unauthorized` means the bearer token is invalid or the user is gone.
284 /// `Transport` covers network and 5xx. `BadResponse` covers other 4xx and parse
285 /// errors. The login callback retries on `Transport`; `refresh_session` does
286 /// not, the client can retry.
287 async fn fetch_userinfo(
288 http: &reqwest::Client,
289 base_url: &str,
290 access_token: &str,
291 ) -> Result<UserinfoResponse, UserinfoError> {
292 let url = format!("{base_url}/oauth/userinfo");
293 let res = http
294 .get(&url)
295 .bearer_auth(access_token)
296 // Per-request timeout, tighter than the client's 15s total: an OAuth call
297 // sits in the login/refresh path and should give up well before the outer
298 // 30s request TimeoutLayer would (fuzz-2026-07-06 OAuth per-request timeout).
299 .timeout(OAUTH_REQUEST_TIMEOUT)
300 .send()
301 .await
302 .map_err(|e| {
303 tracing::warn!(error = %e, "userinfo transport error");
304 UserinfoError::Transport
305 })?;
306
307 let status = res.status();
308 if status == reqwest::StatusCode::UNAUTHORIZED {
309 return Err(UserinfoError::Unauthorized);
310 }
311 if status.is_server_error() {
312 return Err(UserinfoError::Transport);
313 }
314 if !status.is_success() {
315 let body = res.text().await.unwrap_or_default();
316 tracing::warn!(%status, %body, "userinfo non-success");
317 return Err(UserinfoError::BadResponse);
318 }
319
320 res.json::<UserinfoResponse>().await.map_err(|e| {
321 tracing::warn!(error = %e, "userinfo parse failed");
322 UserinfoError::BadResponse
323 })
324 }
325
326 /// Exchange the stored refresh token for a fresh short-lived access token (and a
327 /// rotated refresh token). `RefreshUnavailable` distinguishes a dead refresh
328 /// token (`invalid_grant` / other 4xx) from transport failure.
329 async fn exchange_refresh_token(
330 state: &AppState,
331 refresh_token: &str,
332 ) -> Result<TokenResponse, UserinfoError> {
333 let url = format!("{}/oauth/token", state.config.mnw_base_url);
334 let res = state
335 .http
336 .post(&url)
337 .form(&[
338 ("grant_type", "refresh_token"),
339 ("refresh_token", refresh_token),
340 ("client_id", state.config.oauth_client_id.as_str()),
341 ])
342 .timeout(OAUTH_REQUEST_TIMEOUT)
343 .send()
344 .await
345 .map_err(|e| {
346 tracing::warn!(error = %e, "refresh token transport error");
347 UserinfoError::Transport
348 })?;
349
350 let status = res.status();
351 if status.is_success() {
352 return res.json::<TokenResponse>().await.map_err(|e| {
353 tracing::warn!(error = %e, "refresh token response parse failed");
354 UserinfoError::BadResponse
355 });
356 }
357 if status.is_server_error() {
358 return Err(UserinfoError::Transport);
359 }
360 // 4xx, invalid_grant (expired/rotated/revoked) or bad request.
361 let body = res.text().await.unwrap_or_default();
362 tracing::warn!(%status, %body, "refresh token exchange rejected");
363 Err(UserinfoError::RefreshUnavailable)
364 }
365
366 /// Write a fresh userinfo snapshot into the session and mirror perks to the
367 /// local users table.
368 async fn apply_userinfo(state: &AppState, session: &Session, info: &UserinfoResponse) {
369 if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await {
370 tracing::error!(error = %e, "failed to save refreshed perks");
371 }
372 if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await {
373 tracing::error!(error = %e, "failed to save refreshed username");
374 }
375 if let Err(e) = session
376 .insert(SESSION_DISPLAY_NAME, &info.display_name)
377 .await
378 {
379 tracing::error!(error = %e, "failed to save refreshed display_name");
380 }
381 // Mirror the full identity snapshot (username/display_name/avatar_url + perks)
382 // into the users table so *other* users' posts JOIN against the current author
383 // row, not a login-time freeze. This reuses the exact login upsert, including
384 // the stale-username vacate, so "refresh" and "login" can never drift into two
385 // different mirror shapes.
386 // Best-effort: rendering tolerates a momentarily stale row.
387 if let Err(e) = upsert_login_user(&state.db, info).await {
388 tracing::warn!(error = %e, "failed to mirror refreshed identity to users table");
389 }
390 }
391
392 /// Refresh the cached perks for the current session via the MNW refresh token.
393 ///
394 /// Trades the stored (scoped, rotating) refresh token for a short-lived access
395 /// token, persists the rotated refresh token, fetches userinfo with the access
396 /// token, and updates cached perks. **Never tears down the MT session**: login
397 /// longevity is governed by the session itself, so a dead refresh token yields
398 /// `RefreshUnavailable` (and clears the stored token) rather than logging the
399 /// user out, they keep last-known perks and can re-link their MNW account.
400 pub async fn refresh_session(
401 state: &AppState,
402 session: &Session,
403 ) -> Result<UserPerks, UserinfoError> {
404 let refresh_token: String = session
405 .get(SESSION_REFRESH_TOKEN)
406 .await
407 .unwrap_or(None)
408 .ok_or(UserinfoError::RefreshUnavailable)?;
409
410 let token = match exchange_refresh_token(state, &refresh_token).await {
411 Ok(t) => t,
412 Err(UserinfoError::RefreshUnavailable) => {
413 // Dead refresh token, drop it, but keep the user logged in.
414 if let Err(e) = session.remove::<String>(SESSION_REFRESH_TOKEN).await {
415 tracing::warn!(error = %e, "failed to remove dead refresh token");
416 }
417 return Err(UserinfoError::RefreshUnavailable);
418 }
419 Err(e) => return Err(e),
420 };
421
422 // Rotation: persist the new refresh token, invalidating the one just used.
423 if let Some(new_rt) = token.refresh_token.as_deref()
424 && let Err(e) = session.insert(SESSION_REFRESH_TOKEN, new_rt).await
425 {
426 tracing::error!(error = %e, "failed to persist rotated refresh token");
427 }
428
429 // The short-lived access token is used here and then discarded, never stored.
430 let info = fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await?;
431 apply_userinfo(state, session, &info).await;
432 Ok(info.perks)
433 }
434
435 // --- handlers
436
437 /// `GET /auth/login`, redirect to MNW OAuth authorize endpoint.
438 #[tracing::instrument(skip_all)]
439 pub async fn login(State(state): State<AppState>, session: Session) -> impl IntoResponse {
440 let verifier = generate_verifier();
441 let challenge = pkce_challenge(&verifier);
442 let oauth_state = generate_state_nonce();
443
444 if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await {
445 tracing::error!(error = %e, "failed to save PKCE verifier to session");
446 }
447 if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await {
448 tracing::error!(error = %e, "failed to save OAuth state to session");
449 }
450
451 // Request scoped userinfo access plus offline_access so MNW issues a
452 // rotating refresh token, MT then holds no long-lived, sync-capable token.
453 let url = format!(
454 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}",
455 state.config.mnw_base_url,
456 urlencoding::encode(&state.config.oauth_client_id),
457 urlencoding::encode(&state.config.oauth_redirect_uri),
458 urlencoding::encode(&oauth_state),
459 urlencoding::encode(&challenge),
460 urlencoding::encode("profile:read perks:read offline_access"),
461 );
462
463 Redirect::to(&url)
464 }
465
466 /// `GET /auth/reverify`, silent perk re-check via OIDC `prompt=none`.
467 ///
468 /// The zero-credential-at-rest alternative to the back-channel refresh token:
469 /// MT bounces the browser through MNW's `/oauth/authorize?prompt=none` (no
470 /// `offline_access`, so no refresh token is issued) and the callback uses the
471 /// returned short-lived token for one userinfo fetch, storing nothing. If the
472 /// MNW session has lapsed, MNW redirects back with `error=login_required` and
473 /// the callback keeps the user's last-known perks. MT dogfoods both this
474 /// and the refresh-token flow as the reference relying-party integration.
475 #[tracing::instrument(skip_all)]
476 pub async fn reverify(State(state): State<AppState>, session: Session) -> impl IntoResponse {
477 let verifier = generate_verifier();
478 let challenge = pkce_challenge(&verifier);
479 let oauth_state = generate_state_nonce();
480
481 if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await {
482 tracing::error!(error = %e, "failed to save PKCE verifier to session");
483 }
484 if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await {
485 tracing::error!(error = %e, "failed to save OAuth state to session");
486 }
487
488 let url = format!(
489 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none",
490 state.config.mnw_base_url,
491 urlencoding::encode(&state.config.oauth_client_id),
492 urlencoding::encode(&state.config.oauth_redirect_uri),
493 urlencoding::encode(&oauth_state),
494 urlencoding::encode(&challenge),
495 urlencoding::encode("profile:read perks:read"),
496 );
497
498 Redirect::to(&url)
499 }
500
501 /// Retry backoffs for the OAuth token/userinfo round trips (two retries).
502 const OAUTH_BACKOFFS: [std::time::Duration; 2] = [
503 std::time::Duration::from_millis(500),
504 std::time::Duration::from_secs(1),
505 ];
506
507 /// Exchange the authorization code for a token, retrying on transport/5xx.
508 ///
509 /// The request body is form-encoded, which RFC 6749 §4.1.3 requires and the
510 /// server's `/oauth/token` enforces by taking `axum::Form`. A JSON body comes
511 /// back 415 with no `error` field, so it reads as a transport failure rather
512 /// than as the wrong content type, which is how it went unnoticed here.
513 ///
514 /// Returns the parsed token on success, or the `?error=` slug to redirect with.
515 /// Parsing happens here so the caller never holds an un-parsed response, there
516 /// is no post-loop `unwrap()` to trip if the retry logic ever changes.
517 async fn exchange_code_for_token(
518 http: &reqwest::Client,
519 config: &crate::config::Config,
520 code: &str,
521 verifier: &str,
522 ) -> Result<TokenResponse, &'static str> {
523 let token_url = format!("{}/oauth/token", config.mnw_base_url);
524 tracing::info!(%token_url, "exchanging code for token");
525 // `attempt` is the retry counter (also logged), and the loop runs one past
526 // the backoff array, an iterator-with-enumerate doesn't fit the N+1 shape.
527 #[allow(clippy::needless_range_loop)]
528 for attempt in 0..=OAUTH_BACKOFFS.len() {
529 let res = http
530 .post(&token_url)
531 .form(&[
532 ("grant_type", "authorization_code"),
533 ("code", code),
534 ("redirect_uri", config.oauth_redirect_uri.as_str()),
535 ("code_verifier", verifier),
536 ("client_id", config.oauth_client_id.as_str()),
537 // The server's `key` is the SDK key naming the billing slot an
538 // integration's uploads count against, and it rejects an empty
539 // one on this grant even though the published contract
540 // (server/docs/oauth_integration.md step 3) does not list it.
541 // mt uploads nothing through SyncKit, so the client id is the
542 // honest answer: one slot per registered instance, and the
543 // refresh grant carries the stored key forward without asking.
544 ("key", config.oauth_client_id.as_str()),
545 ])
546 .send()
547 .await;
548
549 match res {
550 Ok(r) if r.status().is_server_error() => {
551 let status = r.status();
552 if attempt < OAUTH_BACKOFFS.len() {
553 tracing::warn!(%status, attempt, "token exchange got 5xx, retrying");
554 sleep(OAUTH_BACKOFFS[attempt]).await;
555 continue;
556 }
557 let body = r.text().await.unwrap_or_default();
558 tracing::error!(%status, %body, "token exchange failed after retries");
559 return Err("token_exchange_failed");
560 }
561 Ok(r) if !r.status().is_success() => {
562 let status = r.status();
563 let body = r.text().await.unwrap_or_default();
564 tracing::error!(%status, %body, "token exchange failed");
565 return Err("token_exchange_failed");
566 }
567 Ok(r) => {
568 return r.json().await.map_err(|e| {
569 tracing::error!(error = %e, "token parse failed");
570 "token_parse_failed"
571 });
572 }
573 Err(e) => {
574 if attempt < OAUTH_BACKOFFS.len() {
575 tracing::warn!(error = %e, attempt, "token request failed, retrying");
576 sleep(OAUTH_BACKOFFS[attempt]).await;
577 continue;
578 }
579 tracing::error!(error = %e, "token request failed after retries");
580 return Err("token_request_failed");
581 }
582 }
583 }
584 // Unreachable: the loop returns on every terminal branch. Kept total so a
585 // future edit to the retry logic can't reintroduce a panic path.
586 Err("token_request_failed")
587 }
588
589 /// Fetch userinfo, retrying on transport/5xx. Returns userinfo or the `?error=`
590 /// slug to redirect with. No post-loop `expect()`, the loop returns on success.
591 async fn fetch_userinfo_with_retry(
592 http: &reqwest::Client,
593 base_url: &str,
594 access_token: &str,
595 ) -> Result<UserinfoResponse, &'static str> {
596 #[allow(clippy::needless_range_loop)]
597 for attempt in 0..=OAUTH_BACKOFFS.len() {
598 match fetch_userinfo(http, base_url, access_token).await {
599 Ok(i) => return Ok(i),
600 Err(UserinfoError::Transport) if attempt < OAUTH_BACKOFFS.len() => {
601 tracing::warn!(attempt, "userinfo transport error, retrying");
602 sleep(OAUTH_BACKOFFS[attempt]).await;
603 }
604 Err(UserinfoError::Transport) => {
605 tracing::error!("userinfo transport failed after retries");
606 return Err("userinfo_fetch_failed");
607 }
608 Err(UserinfoError::Unauthorized) => {
609 tracing::error!("userinfo unauthorized: token rejected");
610 return Err("userinfo_fetch_failed");
611 }
612 Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => {
613 // RefreshUnavailable is unreachable from fetch_userinfo (it's a
614 // refresh-grant outcome), but the match must be exhaustive.
615 tracing::error!("userinfo bad response");
616 return Err("userinfo_parse_failed");
617 }
618 }
619 }
620 Err("userinfo_fetch_failed")
621 }
622
623 /// Upsert the local user row from userinfo on login. `is_fan_plus`/`is_creator`
624 /// are denormalised here so post rendering can JOIN the author's perks (migration
625 /// 026). The stale-username vacate and the upsert run on one transaction so the
626 /// freed name is visible to the insert (S2).
627 async fn upsert_login_user(db: &sqlx::PgPool, info: &UserinfoResponse) -> Result<(), sqlx::Error> {
628 let mut tx = db.begin().await?;
629 mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?;
630 sqlx::query(
631 r"
632 INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator)
633 VALUES ($1, $2, $3, $4, $5, $6)
634 ON CONFLICT (mnw_account_id) DO UPDATE
635 SET username = $2, display_name = $3, avatar_url = $4,
636 is_fan_plus = $5, is_creator = $6, updated_at = now()
637 ",
638 )
639 .bind(info.user_id)
640 .bind(&info.username)
641 .bind(&info.display_name)
642 .bind(&info.avatar_url)
643 .bind(info.perks.fan_plus)
644 .bind(info.perks.is_creator)
645 .execute(&mut *tx)
646 .await?;
647 tx.commit().await?;
648 Ok(())
649 }
650
651 /// `GET /auth/callback`, exchange code for token, fetch userinfo, create session.
652 #[tracing::instrument(skip_all)]
653 pub async fn callback(
654 State(state): State<AppState>,
655 session: Session,
656 Query(params): Query<CallbackQuery>,
657 ) -> impl IntoResponse {
658 tracing::info!("OAuth callback received");
659
660 // Read and immediately consume the one-time OAuth params. Removing them up
661 // front makes both single-use, so a failed state check, or a replayed
662 // callback, cannot leave a reusable PKCE verifier behind in the session.
663 let stored_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.unwrap_or(None);
664 let stored_verifier: Option<String> = session.get(SESSION_PKCE_VERIFIER).await.unwrap_or(None);
665 if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
666 tracing::warn!(error = %e, "failed to remove OAuth state from session");
667 }
668 if let Err(e) = session.remove::<String>(SESSION_PKCE_VERIFIER).await {
669 tracing::warn!(error = %e, "failed to remove PKCE verifier from session");
670 }
671
672 // Verify state nonce in constant time, it's a CSRF token, so compare it on
673 // the same timing-safe path as every other secret (no early-exit on length
674 // or first differing byte).
675 let state_ok = stored_state
676 .as_deref()
677 .is_some_and(|s| crate::csrf::constant_time_compare(s, &params.state));
678 if !state_ok {
679 tracing::warn!(stored = ?stored_state, received = %params.state, "state mismatch");
680 return Redirect::to("/?error=state_mismatch");
681 }
682
683 // A `prompt=none` silent attempt that couldn't proceed returns an error and
684 // no code (e.g. the MNW session lapsed). Keep the user logged in with their
685 // last-known perks; this is a non-event, not a login failure.
686 if let Some(err) = params.error.as_deref() {
687 tracing::info!(error = %err, "silent re-auth returned without a code");
688 return Redirect::to("/");
689 }
690
691 let code = match params.code.as_deref() {
692 Some(c) => c.to_string(),
693 None => {
694 tracing::warn!("callback missing both code and error");
695 return Redirect::to("/?error=missing_code");
696 }
697 };
698
699 let verifier: String = match stored_verifier {
700 Some(v) => v,
701 None => {
702 tracing::warn!("missing PKCE verifier in session");
703 return Redirect::to("/?error=missing_verifier");
704 }
705 };
706
707 // Exchange code for token, then fetch userinfo, each retries on transport/5xx
708 // and returns the `?error=` slug to redirect with on failure.
709 let token = match exchange_code_for_token(&state.http, &state.config, &code, &verifier).await {
710 Ok(t) => t,
711 Err(slug) => return Redirect::to(&format!("/?error={slug}")),
712 };
713
714 tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo");
715 let info = match fetch_userinfo_with_retry(
716 &state.http,
717 &state.config.mnw_base_url,
718 &token.access_token,
719 )
720 .await
721 {
722 Ok(i) => i,
723 Err(slug) => return Redirect::to(&format!("/?error={slug}")),
724 };
725
726 tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful");
727
728 if let Err(e) = upsert_login_user(&state.db, &info).await {
729 tracing::error!(error = %e, "user upsert failed");
730 return Redirect::to("/?error=user_upsert_failed");
731 }
732
733 // Check if user is suspended (fail-closed: DB errors block login)
734 let suspended: bool = match sqlx::query_scalar(
735 "SELECT suspended_at IS NOT NULL FROM users WHERE mnw_account_id = $1",
736 )
737 .bind(info.user_id)
738 .fetch_one(&state.db)
739 .await
740 {
741 Ok(v) => v,
742 Err(e) => {
743 tracing::error!(error = %e, "db error checking suspension status");
744 return Redirect::to("/?error=internal_error");
745 }
746 };
747
748 if suspended {
749 return Redirect::to("/?error=account_suspended");
750 }
751
752 // Save session, perks come from the same userinfo response, no second roundtrip.
753 let session_user = SessionUser {
754 user_id: info.user_id,
755 username: info.username,
756 display_name: info.display_name,
757 perks: info.perks,
758 };
759 session_user.save_to_session(&session).await;
760 // Store the rotating refresh token (NOT the access token) so future perk
761 // refreshes can mint short-lived access tokens without another OAuth round
762 // trip. The access token was already used for the userinfo fetch above and
763 // is now discarded. A provider that declined offline_access returns no
764 // refresh token; then perk-refresh is unavailable until re-login.
765 if let Some(refresh_token) = token.refresh_token.as_deref() {
766 if let Err(e) = session.insert(SESSION_REFRESH_TOKEN, refresh_token).await {
767 tracing::error!(error = %e, "failed to save refresh token to session");
768 }
769 } else {
770 tracing::warn!(
771 "token response carried no refresh token; perk refresh disabled this session"
772 );
773 }
774 if let Err(e) = session.cycle_id().await {
775 tracing::warn!(error = %e, "Failed to cycle session ID");
776 }
777 tracing::info!("session saved, redirecting to /");
778
779 Redirect::to("/")
780 }
781
782 /// `POST /auth/refresh`, re-fetch MNW userinfo and overwrite cached perks.
783 ///
784 /// Useful after the user takes an action that changed their MNW entitlements
785 /// (e.g., subscribing to Fan+, upgrading a creator tier) so they don't have to
786 /// log out and back in to see the new perks. Returns the refreshed perks as
787 /// JSON.
788 #[tracing::instrument(skip_all)]
789 pub async fn refresh(
790 State(state): State<AppState>,
791 session: Session,
792 ) -> Result<Json<RefreshResponse>, StatusCode> {
793 match refresh_session(&state, &session).await {
794 Ok(perks) => Ok(Json(RefreshResponse { perks })),
795 // 401 means "perks couldn't be refreshed", NOT logged out. The session
796 // is intact; the frontend can surface a re-link affordance. No flush.
797 Err(UserinfoError::Unauthorized | UserinfoError::RefreshUnavailable) => {
798 Err(StatusCode::UNAUTHORIZED)
799 }
800 Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY),
801 Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY),
802 }
803 }
804
805 #[derive(Serialize)]
806 pub struct RefreshResponse {
807 pub perks: UserPerks,
808 }
809
810 /// `POST /auth/logout`, flush session, redirect home.
811 #[tracing::instrument(skip_all)]
812 pub async fn logout(session: Session) -> impl IntoResponse {
813 if let Err(e) = session.flush().await {
814 tracing::warn!(error = %e, "failed to flush session on logout");
815 }
816 Redirect::to("/")
817 }
818
819 #[cfg(test)]
820 mod tests {
821 use super::*;
822
823 #[test]
824 fn pkce_challenge_matches_rfc7636_test_vector() {
825 // RFC 7636 Appendix B known-answer vector.
826 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
827 let challenge = pkce_challenge(verifier);
828 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
829 }
830
831 #[test]
832 fn pkce_challenge_is_deterministic() {
833 let v = generate_verifier();
834 assert_eq!(pkce_challenge(&v), pkce_challenge(&v));
835 }
836
837 #[test]
838 fn verifier_is_url_safe_base64_of_32_bytes() {
839 let v = generate_verifier();
840 // 32 bytes → 43 chars of unpadded base64url.
841 assert_eq!(v.len(), 43);
842 assert!(!v.contains('='), "must be unpadded");
843 assert!(
844 v.bytes()
845 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
846 "must be url-safe: {v}"
847 );
848 // And the challenge is likewise url-safe/unpadded (sent as a query param).
849 let c = pkce_challenge(&v);
850 assert_eq!(c.len(), 43);
851 assert!(!c.contains('='));
852 }
853
854 #[test]
855 fn verifier_and_nonce_are_unpredictable() {
856 // Sanity that we're not returning a constant. Collisions across 32/16
857 // random bytes are astronomically unlikely, so equality means a bug.
858 assert_ne!(generate_verifier(), generate_verifier());
859 assert_ne!(generate_state_nonce(), generate_state_nonce());
860 }
861
862 #[test]
863 fn state_nonce_is_128_bits_of_hex() {
864 let n = generate_state_nonce();
865 assert_eq!(n.len(), 32); // 16 bytes → 32 hex chars
866 assert!(n.bytes().all(|b| b.is_ascii_hexdigit()));
867 }
868
869 #[test]
870 fn state_comparison_is_constant_time_and_correct() {
871 // The callback compares the returned `state` against the session nonce via
872 // this shared constant-time primitive (auth.rs). Guard the wiring here.
873 assert!(crate::csrf::constant_time_compare("abc123", "abc123"));
874 assert!(!crate::csrf::constant_time_compare("abc123", "abc124"));
875 assert!(!crate::csrf::constant_time_compare("abc", "abc123"));
876 }
877 }
878