Skip to main content

max / makenotwork

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