Skip to main content

max / makenotwork

32.7 KB · 865 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 .json(&serde_json::json!({
338 "grant_type": "refresh_token",
339 "refresh_token": refresh_token,
340 "client_id": state.config.oauth_client_id,
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 /// Returns the parsed token on success, or the `?error=` slug to redirect with.
510 /// Parsing happens here so the caller never holds an un-parsed response, there
511 /// is no post-loop `unwrap()` to trip if the retry logic ever changes.
512 async fn exchange_code_for_token(
513 http: &reqwest::Client,
514 config: &crate::config::Config,
515 code: &str,
516 verifier: &str,
517 ) -> Result<TokenResponse, &'static str> {
518 let token_url = format!("{}/oauth/token", config.mnw_base_url);
519 tracing::info!(%token_url, "exchanging code for token");
520 // `attempt` is the retry counter (also logged), and the loop runs one past
521 // the backoff array, an iterator-with-enumerate doesn't fit the N+1 shape.
522 #[allow(clippy::needless_range_loop)]
523 for attempt in 0..=OAUTH_BACKOFFS.len() {
524 let res = http
525 .post(&token_url)
526 .json(&serde_json::json!({
527 "grant_type": "authorization_code",
528 "code": code,
529 "redirect_uri": config.oauth_redirect_uri,
530 "code_verifier": verifier,
531 "client_id": config.oauth_client_id,
532 }))
533 .send()
534 .await;
535
536 match res {
537 Ok(r) if r.status().is_server_error() => {
538 let status = r.status();
539 if attempt < OAUTH_BACKOFFS.len() {
540 tracing::warn!(%status, attempt, "token exchange got 5xx, retrying");
541 sleep(OAUTH_BACKOFFS[attempt]).await;
542 continue;
543 }
544 let body = r.text().await.unwrap_or_default();
545 tracing::error!(%status, %body, "token exchange failed after retries");
546 return Err("token_exchange_failed");
547 }
548 Ok(r) if !r.status().is_success() => {
549 let status = r.status();
550 let body = r.text().await.unwrap_or_default();
551 tracing::error!(%status, %body, "token exchange failed");
552 return Err("token_exchange_failed");
553 }
554 Ok(r) => {
555 return r.json().await.map_err(|e| {
556 tracing::error!(error = %e, "token parse failed");
557 "token_parse_failed"
558 });
559 }
560 Err(e) => {
561 if attempt < OAUTH_BACKOFFS.len() {
562 tracing::warn!(error = %e, attempt, "token request failed, retrying");
563 sleep(OAUTH_BACKOFFS[attempt]).await;
564 continue;
565 }
566 tracing::error!(error = %e, "token request failed after retries");
567 return Err("token_request_failed");
568 }
569 }
570 }
571 // Unreachable: the loop returns on every terminal branch. Kept total so a
572 // future edit to the retry logic can't reintroduce a panic path.
573 Err("token_request_failed")
574 }
575
576 /// Fetch userinfo, retrying on transport/5xx. Returns userinfo or the `?error=`
577 /// slug to redirect with. No post-loop `expect()`, the loop returns on success.
578 async fn fetch_userinfo_with_retry(
579 http: &reqwest::Client,
580 base_url: &str,
581 access_token: &str,
582 ) -> Result<UserinfoResponse, &'static str> {
583 #[allow(clippy::needless_range_loop)]
584 for attempt in 0..=OAUTH_BACKOFFS.len() {
585 match fetch_userinfo(http, base_url, access_token).await {
586 Ok(i) => return Ok(i),
587 Err(UserinfoError::Transport) if attempt < OAUTH_BACKOFFS.len() => {
588 tracing::warn!(attempt, "userinfo transport error, retrying");
589 sleep(OAUTH_BACKOFFS[attempt]).await;
590 }
591 Err(UserinfoError::Transport) => {
592 tracing::error!("userinfo transport failed after retries");
593 return Err("userinfo_fetch_failed");
594 }
595 Err(UserinfoError::Unauthorized) => {
596 tracing::error!("userinfo unauthorized: token rejected");
597 return Err("userinfo_fetch_failed");
598 }
599 Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => {
600 // RefreshUnavailable is unreachable from fetch_userinfo (it's a
601 // refresh-grant outcome), but the match must be exhaustive.
602 tracing::error!("userinfo bad response");
603 return Err("userinfo_parse_failed");
604 }
605 }
606 }
607 Err("userinfo_fetch_failed")
608 }
609
610 /// Upsert the local user row from userinfo on login. `is_fan_plus`/`is_creator`
611 /// are denormalised here so post rendering can JOIN the author's perks (migration
612 /// 026). The stale-username vacate and the upsert run on one transaction so the
613 /// freed name is visible to the insert (S2).
614 async fn upsert_login_user(db: &sqlx::PgPool, info: &UserinfoResponse) -> Result<(), sqlx::Error> {
615 let mut tx = db.begin().await?;
616 mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?;
617 sqlx::query(
618 r"
619 INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator)
620 VALUES ($1, $2, $3, $4, $5, $6)
621 ON CONFLICT (mnw_account_id) DO UPDATE
622 SET username = $2, display_name = $3, avatar_url = $4,
623 is_fan_plus = $5, is_creator = $6, updated_at = now()
624 ",
625 )
626 .bind(info.user_id)
627 .bind(&info.username)
628 .bind(&info.display_name)
629 .bind(&info.avatar_url)
630 .bind(info.perks.fan_plus)
631 .bind(info.perks.is_creator)
632 .execute(&mut *tx)
633 .await?;
634 tx.commit().await?;
635 Ok(())
636 }
637
638 /// `GET /auth/callback`, exchange code for token, fetch userinfo, create session.
639 #[tracing::instrument(skip_all)]
640 pub async fn callback(
641 State(state): State<AppState>,
642 session: Session,
643 Query(params): Query<CallbackQuery>,
644 ) -> impl IntoResponse {
645 tracing::info!("OAuth callback received");
646
647 // Read and immediately consume the one-time OAuth params. Removing them up
648 // front makes both single-use, so a failed state check, or a replayed
649 // callback, cannot leave a reusable PKCE verifier behind in the session.
650 let stored_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.unwrap_or(None);
651 let stored_verifier: Option<String> = session.get(SESSION_PKCE_VERIFIER).await.unwrap_or(None);
652 if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
653 tracing::warn!(error = %e, "failed to remove OAuth state from session");
654 }
655 if let Err(e) = session.remove::<String>(SESSION_PKCE_VERIFIER).await {
656 tracing::warn!(error = %e, "failed to remove PKCE verifier from session");
657 }
658
659 // Verify state nonce in constant time, it's a CSRF token, so compare it on
660 // the same timing-safe path as every other secret (no early-exit on length
661 // or first differing byte).
662 let state_ok = stored_state
663 .as_deref()
664 .is_some_and(|s| crate::csrf::constant_time_compare(s, &params.state));
665 if !state_ok {
666 tracing::warn!(stored = ?stored_state, received = %params.state, "state mismatch");
667 return Redirect::to("/?error=state_mismatch");
668 }
669
670 // A `prompt=none` silent attempt that couldn't proceed returns an error and
671 // no code (e.g. the MNW session lapsed). Keep the user logged in with their
672 // last-known perks; this is a non-event, not a login failure.
673 if let Some(err) = params.error.as_deref() {
674 tracing::info!(error = %err, "silent re-auth returned without a code");
675 return Redirect::to("/");
676 }
677
678 let code = match params.code.as_deref() {
679 Some(c) => c.to_string(),
680 None => {
681 tracing::warn!("callback missing both code and error");
682 return Redirect::to("/?error=missing_code");
683 }
684 };
685
686 let verifier: String = match stored_verifier {
687 Some(v) => v,
688 None => {
689 tracing::warn!("missing PKCE verifier in session");
690 return Redirect::to("/?error=missing_verifier");
691 }
692 };
693
694 // Exchange code for token, then fetch userinfo, each retries on transport/5xx
695 // and returns the `?error=` slug to redirect with on failure.
696 let token = match exchange_code_for_token(&state.http, &state.config, &code, &verifier).await {
697 Ok(t) => t,
698 Err(slug) => return Redirect::to(&format!("/?error={slug}")),
699 };
700
701 tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo");
702 let info = match fetch_userinfo_with_retry(
703 &state.http,
704 &state.config.mnw_base_url,
705 &token.access_token,
706 )
707 .await
708 {
709 Ok(i) => i,
710 Err(slug) => return Redirect::to(&format!("/?error={slug}")),
711 };
712
713 tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful");
714
715 if let Err(e) = upsert_login_user(&state.db, &info).await {
716 tracing::error!(error = %e, "user upsert failed");
717 return Redirect::to("/?error=user_upsert_failed");
718 }
719
720 // Check if user is suspended (fail-closed: DB errors block login)
721 let suspended: bool = match sqlx::query_scalar(
722 "SELECT suspended_at IS NOT NULL FROM users WHERE mnw_account_id = $1",
723 )
724 .bind(info.user_id)
725 .fetch_one(&state.db)
726 .await
727 {
728 Ok(v) => v,
729 Err(e) => {
730 tracing::error!(error = %e, "db error checking suspension status");
731 return Redirect::to("/?error=internal_error");
732 }
733 };
734
735 if suspended {
736 return Redirect::to("/?error=account_suspended");
737 }
738
739 // Save session, perks come from the same userinfo response, no second roundtrip.
740 let session_user = SessionUser {
741 user_id: info.user_id,
742 username: info.username,
743 display_name: info.display_name,
744 perks: info.perks,
745 };
746 session_user.save_to_session(&session).await;
747 // Store the rotating refresh token (NOT the access token) so future perk
748 // refreshes can mint short-lived access tokens without another OAuth round
749 // trip. The access token was already used for the userinfo fetch above and
750 // is now discarded. A provider that declined offline_access returns no
751 // refresh token; then perk-refresh is unavailable until re-login.
752 if let Some(refresh_token) = token.refresh_token.as_deref() {
753 if let Err(e) = session.insert(SESSION_REFRESH_TOKEN, refresh_token).await {
754 tracing::error!(error = %e, "failed to save refresh token to session");
755 }
756 } else {
757 tracing::warn!(
758 "token response carried no refresh token; perk refresh disabled this session"
759 );
760 }
761 if let Err(e) = session.cycle_id().await {
762 tracing::warn!(error = %e, "Failed to cycle session ID");
763 }
764 tracing::info!("session saved, redirecting to /");
765
766 Redirect::to("/")
767 }
768
769 /// `POST /auth/refresh`, re-fetch MNW userinfo and overwrite cached perks.
770 ///
771 /// Useful after the user takes an action that changed their MNW entitlements
772 /// (e.g., subscribing to Fan+, upgrading a creator tier) so they don't have to
773 /// log out and back in to see the new perks. Returns the refreshed perks as
774 /// JSON.
775 #[tracing::instrument(skip_all)]
776 pub async fn refresh(
777 State(state): State<AppState>,
778 session: Session,
779 ) -> Result<Json<RefreshResponse>, StatusCode> {
780 match refresh_session(&state, &session).await {
781 Ok(perks) => Ok(Json(RefreshResponse { perks })),
782 // 401 means "perks couldn't be refreshed", NOT logged out. The session
783 // is intact; the frontend can surface a re-link affordance. No flush.
784 Err(UserinfoError::Unauthorized | UserinfoError::RefreshUnavailable) => {
785 Err(StatusCode::UNAUTHORIZED)
786 }
787 Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY),
788 Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY),
789 }
790 }
791
792 #[derive(Serialize)]
793 pub struct RefreshResponse {
794 pub perks: UserPerks,
795 }
796
797 /// `POST /auth/logout`, flush session, redirect home.
798 #[tracing::instrument(skip_all)]
799 pub async fn logout(session: Session) -> impl IntoResponse {
800 if let Err(e) = session.flush().await {
801 tracing::warn!(error = %e, "failed to flush session on logout");
802 }
803 Redirect::to("/")
804 }
805
806 #[cfg(test)]
807 mod tests {
808 use super::*;
809
810 #[test]
811 fn pkce_challenge_matches_rfc7636_test_vector() {
812 // RFC 7636 Appendix B known-answer vector.
813 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
814 let challenge = pkce_challenge(verifier);
815 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
816 }
817
818 #[test]
819 fn pkce_challenge_is_deterministic() {
820 let v = generate_verifier();
821 assert_eq!(pkce_challenge(&v), pkce_challenge(&v));
822 }
823
824 #[test]
825 fn verifier_is_url_safe_base64_of_32_bytes() {
826 let v = generate_verifier();
827 // 32 bytes → 43 chars of unpadded base64url.
828 assert_eq!(v.len(), 43);
829 assert!(!v.contains('='), "must be unpadded");
830 assert!(
831 v.bytes()
832 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
833 "must be url-safe: {v}"
834 );
835 // And the challenge is likewise url-safe/unpadded (sent as a query param).
836 let c = pkce_challenge(&v);
837 assert_eq!(c.len(), 43);
838 assert!(!c.contains('='));
839 }
840
841 #[test]
842 fn verifier_and_nonce_are_unpredictable() {
843 // Sanity that we're not returning a constant. Collisions across 32/16
844 // random bytes are astronomically unlikely, so equality means a bug.
845 assert_ne!(generate_verifier(), generate_verifier());
846 assert_ne!(generate_state_nonce(), generate_state_nonce());
847 }
848
849 #[test]
850 fn state_nonce_is_128_bits_of_hex() {
851 let n = generate_state_nonce();
852 assert_eq!(n.len(), 32); // 16 bytes → 32 hex chars
853 assert!(n.bytes().all(|b| b.is_ascii_hexdigit()));
854 }
855
856 #[test]
857 fn state_comparison_is_constant_time_and_correct() {
858 // The callback compares the returned `state` against the session nonce via
859 // this shared constant-time primitive (auth.rs). Guard the wiring here.
860 assert!(crate::csrf::constant_time_compare("abc123", "abc123"));
861 assert!(!crate::csrf::constant_time_compare("abc123", "abc124"));
862 assert!(!crate::csrf::constant_time_compare("abc", "abc123"));
863 }
864 }
865