//! OAuth client for "Log in with Makenot.work" and session user extraction. //! //! Perks (Fan+, creator tier, capabilities) come from MNW's `/oauth/userinfo` //! `perks` object. We cache them in the session and refresh on three triggers: //! (1) login, (2) session cycle, (3) on-demand via `POST /auth/refresh`. See //! `MNW/server/docs/oauth_integration.md` for the contract. use axum::{ Json, extract::{FromRequestParts, Query, State}, http::{StatusCode, request::Parts}, response::{IntoResponse, Redirect}, }; use base64::Engine; use rand::Rng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::time::sleep; use tower_sessions::Session; use crate::AppState; // --- PKCE helpers fn generate_verifier() -> String { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } fn pkce_challenge(verifier: &str) -> String { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); let digest = hasher.finalize(); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } fn generate_state_nonce() -> String { let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } // --- session user /// User info cached in the session after OAuth login. /// /// `perks` reflects MNW state at the last refresh (login, session cycle, or /// explicit `POST /auth/refresh`). Use [`UserPerks::effective_plus`] for the /// canonical Fan+ gate. #[derive(Clone, Debug)] pub struct SessionUser { pub user_id: uuid::Uuid, pub username: String, pub display_name: Option, pub perks: UserPerks, } /// Capability snapshot from MNW's `/oauth/userinfo` `perks` object. /// /// Default = no perks; this is what unknown / not-yet-refreshed sessions see. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct UserPerks { #[serde(default)] pub fan_plus: bool, #[serde(default)] pub is_creator: bool, #[serde(default)] pub creator_tier: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreatorTierInfo { pub tier: String, pub features: Vec, } impl UserPerks { /// Canonical "should this user see + features" check. True for active Fan+ /// subscribers and for any creator (auto-grant: creators get + perks without /// paying for Fan+ separately). pub fn effective_plus(&self) -> bool { self.fan_plus || self.is_creator } } const SESSION_USER_ID: &str = "user_id"; const SESSION_USERNAME: &str = "username"; const SESSION_DISPLAY_NAME: &str = "display_name"; const SESSION_PERKS: &str = "perks"; /// The MNW **refresh** token, scoped (`perks:read`/`profile:read`), rotating, /// and unable to act as the user on the sync API. This is the only MNW /// credential stored at rest; the short-lived access /// token is used transiently for one userinfo fetch and never persisted. const SESSION_REFRESH_TOKEN: &str = "mnw_refresh_token"; const SESSION_OAUTH_STATE: &str = "oauth_state"; const SESSION_PKCE_VERIFIER: &str = "pkce_verifier"; /// Per-request timeout for the outbound OAuth calls (userinfo, token refresh). /// Tighter than the shared client's 15s total and the outer 30s TimeoutLayer, so /// a stalled MNW OAuth endpoint fails the login/refresh fast instead of parking. const OAUTH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); impl SessionUser { async fn from_session(session: &Session) -> Option { let user_id: uuid::Uuid = match session.get(SESSION_USER_ID).await { Ok(v) => v?, Err(e) => { tracing::warn!(error = %e, "failed to read user_id from session"); return None; } }; let username: String = match session.get(SESSION_USERNAME).await { Ok(v) => v?, Err(e) => { tracing::warn!(error = %e, "failed to read username from session"); return None; } }; let display_name: Option = match session.get(SESSION_DISPLAY_NAME).await { Ok(v) => v, Err(e) => { tracing::warn!(error = %e, "failed to read display_name from session"); None } }; // Perks default to empty, sessions predating the perks change still load. let perks: UserPerks = session .get(SESSION_PERKS) .await .unwrap_or_default() .unwrap_or_default(); Some(Self { user_id, username, display_name, perks, }) } async fn save_to_session(&self, session: &Session) { if let Err(e) = session.insert(SESSION_USER_ID, self.user_id).await { tracing::error!(error = %e, "failed to save user_id to session"); } if let Err(e) = session.insert(SESSION_USERNAME, &self.username).await { tracing::error!(error = %e, "failed to save username to session"); } if let Err(e) = session .insert(SESSION_DISPLAY_NAME, &self.display_name) .await { tracing::error!(error = %e, "failed to save display_name to session"); } if let Err(e) = session.insert(SESSION_PERKS, &self.perks).await { tracing::error!(error = %e, "failed to save perks to session"); } } } /// Axum extractor that yields `Option`. pub struct MaybeUser(pub Option); impl FromRequestParts for MaybeUser { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let session = Session::from_request_parts(parts, state) .await .expect("session layer missing"); Ok(MaybeUser(SessionUser::from_session(&session).await)) } } /// Axum extractor that requires an authenticated session. /// /// Yields the [`SessionUser`] directly, or rejects with a redirect to /// `/auth/login`. Use this instead of `MaybeUser` whenever the handler needs a /// logged-in user. pub struct RequireUser(pub SessionUser); impl FromRequestParts for RequireUser { type Rejection = axum::response::Response; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let session = Session::from_request_parts(parts, state) .await .expect("session layer missing"); let user = SessionUser::from_session(&session) .await .ok_or_else(|| Redirect::to("/auth/login").into_response())?; Ok(RequireUser(user)) } } /// Axum extractor that requires the user to be the platform admin. /// Returns 404 to non-admins (hides admin routes). pub struct PlatformAdmin(pub SessionUser); impl FromRequestParts for PlatformAdmin { type Rejection = StatusCode; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let session = Session::from_request_parts(parts, state) .await .expect("session layer missing"); let user = SessionUser::from_session(&session) .await .ok_or(StatusCode::NOT_FOUND)?; let admin_id = state .config .platform_admin_id .ok_or(StatusCode::NOT_FOUND)?; if user.user_id != admin_id { return Err(StatusCode::NOT_FOUND); } Ok(PlatformAdmin(user)) } } // --- OAuth callback types #[derive(Deserialize)] pub struct CallbackQuery { /// Absent on a `prompt=none` failure, where the provider returns `error`. #[serde(default)] pub code: Option, pub state: String, /// OIDC error (e.g. `login_required`) from a `prompt=none` silent attempt. #[serde(default)] pub error: Option, } #[derive(Deserialize)] struct TokenResponse { access_token: String, /// Present when the grant included `offline_access`. `Option` so a provider /// that declines it (or hasn't shipped refresh tokens) still parses. #[serde(default)] refresh_token: Option, /// Informational only, login longevity is governed by the MT session, not /// the access token. #[serde(default)] #[allow(dead_code)] expires_in: Option, } #[derive(Deserialize)] struct UserinfoResponse { user_id: uuid::Uuid, username: String, display_name: Option, avatar_url: Option, #[serde(default)] perks: UserPerks, } #[derive(Debug)] pub enum UserinfoError { Unauthorized, Transport, BadResponse, /// No usable refresh token: none stored, or the stored one was expired / /// rotated / revoked (`invalid_grant`). The MT session is NOT torn down, /// the user stays logged in with last-known perks and can re-link MNW. RefreshUnavailable, } /// Single-attempt userinfo fetch against MNW. Callers decide retry policy. /// /// `Unauthorized` means the bearer token is invalid or the user is gone. /// `Transport` covers network and 5xx. `BadResponse` covers other 4xx and parse /// errors. The login callback retries on `Transport`; `refresh_session` does /// not, the client can retry. async fn fetch_userinfo( http: &reqwest::Client, base_url: &str, access_token: &str, ) -> Result { let url = format!("{base_url}/oauth/userinfo"); let res = http .get(&url) .bearer_auth(access_token) // Per-request timeout, tighter than the client's 15s total: an OAuth call // sits in the login/refresh path and should give up well before the outer // 30s request TimeoutLayer would (fuzz-2026-07-06 OAuth per-request timeout). .timeout(OAUTH_REQUEST_TIMEOUT) .send() .await .map_err(|e| { tracing::warn!(error = %e, "userinfo transport error"); UserinfoError::Transport })?; let status = res.status(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(UserinfoError::Unauthorized); } if status.is_server_error() { return Err(UserinfoError::Transport); } if !status.is_success() { let body = res.text().await.unwrap_or_default(); tracing::warn!(%status, %body, "userinfo non-success"); return Err(UserinfoError::BadResponse); } res.json::().await.map_err(|e| { tracing::warn!(error = %e, "userinfo parse failed"); UserinfoError::BadResponse }) } /// Exchange the stored refresh token for a fresh short-lived access token (and a /// rotated refresh token). `RefreshUnavailable` distinguishes a dead refresh /// token (`invalid_grant` / other 4xx) from transport failure. async fn exchange_refresh_token( state: &AppState, refresh_token: &str, ) -> Result { let url = format!("{}/oauth/token", state.config.mnw_base_url); let res = state .http .post(&url) .form(&[ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", state.config.oauth_client_id.as_str()), ]) .timeout(OAUTH_REQUEST_TIMEOUT) .send() .await .map_err(|e| { tracing::warn!(error = %e, "refresh token transport error"); UserinfoError::Transport })?; let status = res.status(); if status.is_success() { return res.json::().await.map_err(|e| { tracing::warn!(error = %e, "refresh token response parse failed"); UserinfoError::BadResponse }); } if status.is_server_error() { return Err(UserinfoError::Transport); } // 4xx, invalid_grant (expired/rotated/revoked) or bad request. let body = res.text().await.unwrap_or_default(); tracing::warn!(%status, %body, "refresh token exchange rejected"); Err(UserinfoError::RefreshUnavailable) } /// Write a fresh userinfo snapshot into the session and mirror perks to the /// local users table. async fn apply_userinfo(state: &AppState, session: &Session, info: &UserinfoResponse) { if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await { tracing::error!(error = %e, "failed to save refreshed perks"); } if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await { tracing::error!(error = %e, "failed to save refreshed username"); } if let Err(e) = session .insert(SESSION_DISPLAY_NAME, &info.display_name) .await { tracing::error!(error = %e, "failed to save refreshed display_name"); } // Mirror the full identity snapshot (username/display_name/avatar_url + perks) // into the users table so *other* users' posts JOIN against the current author // row, not a login-time freeze. This reuses the exact login upsert, including // the stale-username vacate, so "refresh" and "login" can never drift into two // different mirror shapes. // Best-effort: rendering tolerates a momentarily stale row. if let Err(e) = upsert_login_user(&state.db, info).await { tracing::warn!(error = %e, "failed to mirror refreshed identity to users table"); } } /// Refresh the cached perks for the current session via the MNW refresh token. /// /// Trades the stored (scoped, rotating) refresh token for a short-lived access /// token, persists the rotated refresh token, fetches userinfo with the access /// token, and updates cached perks. **Never tears down the MT session**: login /// longevity is governed by the session itself, so a dead refresh token yields /// `RefreshUnavailable` (and clears the stored token) rather than logging the /// user out, they keep last-known perks and can re-link their MNW account. pub async fn refresh_session( state: &AppState, session: &Session, ) -> Result { let refresh_token: String = session .get(SESSION_REFRESH_TOKEN) .await .unwrap_or(None) .ok_or(UserinfoError::RefreshUnavailable)?; let token = match exchange_refresh_token(state, &refresh_token).await { Ok(t) => t, Err(UserinfoError::RefreshUnavailable) => { // Dead refresh token, drop it, but keep the user logged in. if let Err(e) = session.remove::(SESSION_REFRESH_TOKEN).await { tracing::warn!(error = %e, "failed to remove dead refresh token"); } return Err(UserinfoError::RefreshUnavailable); } Err(e) => return Err(e), }; // Rotation: persist the new refresh token, invalidating the one just used. if let Some(new_rt) = token.refresh_token.as_deref() && let Err(e) = session.insert(SESSION_REFRESH_TOKEN, new_rt).await { tracing::error!(error = %e, "failed to persist rotated refresh token"); } // The short-lived access token is used here and then discarded, never stored. let info = fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await?; apply_userinfo(state, session, &info).await; Ok(info.perks) } // --- handlers /// `GET /auth/login`, redirect to MNW OAuth authorize endpoint. #[tracing::instrument(skip_all)] pub async fn login(State(state): State, session: Session) -> impl IntoResponse { let verifier = generate_verifier(); let challenge = pkce_challenge(&verifier); let oauth_state = generate_state_nonce(); if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await { tracing::error!(error = %e, "failed to save PKCE verifier to session"); } if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await { tracing::error!(error = %e, "failed to save OAuth state to session"); } // Request scoped userinfo access plus offline_access so MNW issues a // rotating refresh token, MT then holds no long-lived, sync-capable token. let url = format!( "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}", state.config.mnw_base_url, urlencoding::encode(&state.config.oauth_client_id), urlencoding::encode(&state.config.oauth_redirect_uri), urlencoding::encode(&oauth_state), urlencoding::encode(&challenge), urlencoding::encode("profile:read perks:read offline_access"), ); Redirect::to(&url) } /// `GET /auth/reverify`, silent perk re-check via OIDC `prompt=none`. /// /// The zero-credential-at-rest alternative to the back-channel refresh token: /// MT bounces the browser through MNW's `/oauth/authorize?prompt=none` (no /// `offline_access`, so no refresh token is issued) and the callback uses the /// returned short-lived token for one userinfo fetch, storing nothing. If the /// MNW session has lapsed, MNW redirects back with `error=login_required` and /// the callback keeps the user's last-known perks. MT dogfoods both this /// and the refresh-token flow as the reference relying-party integration. #[tracing::instrument(skip_all)] pub async fn reverify(State(state): State, session: Session) -> impl IntoResponse { let verifier = generate_verifier(); let challenge = pkce_challenge(&verifier); let oauth_state = generate_state_nonce(); if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await { tracing::error!(error = %e, "failed to save PKCE verifier to session"); } if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await { tracing::error!(error = %e, "failed to save OAuth state to session"); } let url = format!( "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", state.config.mnw_base_url, urlencoding::encode(&state.config.oauth_client_id), urlencoding::encode(&state.config.oauth_redirect_uri), urlencoding::encode(&oauth_state), urlencoding::encode(&challenge), urlencoding::encode("profile:read perks:read"), ); Redirect::to(&url) } /// Retry backoffs for the OAuth token/userinfo round trips (two retries). const OAUTH_BACKOFFS: [std::time::Duration; 2] = [ std::time::Duration::from_millis(500), std::time::Duration::from_secs(1), ]; /// Exchange the authorization code for a token, retrying on transport/5xx. /// /// The request body is form-encoded, which RFC 6749 §4.1.3 requires and the /// server's `/oauth/token` enforces by taking `axum::Form`. A JSON body comes /// back 415 with no `error` field, so it reads as a transport failure rather /// than as the wrong content type, which is how it went unnoticed here. /// /// Returns the parsed token on success, or the `?error=` slug to redirect with. /// Parsing happens here so the caller never holds an un-parsed response, there /// is no post-loop `unwrap()` to trip if the retry logic ever changes. async fn exchange_code_for_token( http: &reqwest::Client, config: &crate::config::Config, code: &str, verifier: &str, ) -> Result { let token_url = format!("{}/oauth/token", config.mnw_base_url); tracing::info!(%token_url, "exchanging code for token"); // `attempt` is the retry counter (also logged), and the loop runs one past // the backoff array, an iterator-with-enumerate doesn't fit the N+1 shape. #[allow(clippy::needless_range_loop)] for attempt in 0..=OAUTH_BACKOFFS.len() { let res = http .post(&token_url) .form(&[ ("grant_type", "authorization_code"), ("code", code), ("redirect_uri", config.oauth_redirect_uri.as_str()), ("code_verifier", verifier), ("client_id", config.oauth_client_id.as_str()), // The server's `key` is the SDK key naming the billing slot an // integration's uploads count against, and it rejects an empty // one on this grant even though the published contract // (server/docs/oauth_integration.md step 3) does not list it. // mt uploads nothing through SyncKit, so the client id is the // honest answer: one slot per registered instance, and the // refresh grant carries the stored key forward without asking. ("key", config.oauth_client_id.as_str()), ]) .send() .await; match res { Ok(r) if r.status().is_server_error() => { let status = r.status(); if attempt < OAUTH_BACKOFFS.len() { tracing::warn!(%status, attempt, "token exchange got 5xx, retrying"); sleep(OAUTH_BACKOFFS[attempt]).await; continue; } let body = r.text().await.unwrap_or_default(); tracing::error!(%status, %body, "token exchange failed after retries"); return Err("token_exchange_failed"); } Ok(r) if !r.status().is_success() => { let status = r.status(); let body = r.text().await.unwrap_or_default(); tracing::error!(%status, %body, "token exchange failed"); return Err("token_exchange_failed"); } Ok(r) => { return r.json().await.map_err(|e| { tracing::error!(error = %e, "token parse failed"); "token_parse_failed" }); } Err(e) => { if attempt < OAUTH_BACKOFFS.len() { tracing::warn!(error = %e, attempt, "token request failed, retrying"); sleep(OAUTH_BACKOFFS[attempt]).await; continue; } tracing::error!(error = %e, "token request failed after retries"); return Err("token_request_failed"); } } } // Unreachable: the loop returns on every terminal branch. Kept total so a // future edit to the retry logic can't reintroduce a panic path. Err("token_request_failed") } /// Fetch userinfo, retrying on transport/5xx. Returns userinfo or the `?error=` /// slug to redirect with. No post-loop `expect()`, the loop returns on success. async fn fetch_userinfo_with_retry( http: &reqwest::Client, base_url: &str, access_token: &str, ) -> Result { #[allow(clippy::needless_range_loop)] for attempt in 0..=OAUTH_BACKOFFS.len() { match fetch_userinfo(http, base_url, access_token).await { Ok(i) => return Ok(i), Err(UserinfoError::Transport) if attempt < OAUTH_BACKOFFS.len() => { tracing::warn!(attempt, "userinfo transport error, retrying"); sleep(OAUTH_BACKOFFS[attempt]).await; } Err(UserinfoError::Transport) => { tracing::error!("userinfo transport failed after retries"); return Err("userinfo_fetch_failed"); } Err(UserinfoError::Unauthorized) => { tracing::error!("userinfo unauthorized: token rejected"); return Err("userinfo_fetch_failed"); } Err(UserinfoError::BadResponse | UserinfoError::RefreshUnavailable) => { // RefreshUnavailable is unreachable from fetch_userinfo (it's a // refresh-grant outcome), but the match must be exhaustive. tracing::error!("userinfo bad response"); return Err("userinfo_parse_failed"); } } } Err("userinfo_fetch_failed") } /// Upsert the local user row from userinfo on login. `is_fan_plus`/`is_creator` /// are denormalised here so post rendering can JOIN the author's perks (migration /// 026). The stale-username vacate and the upsert run on one transaction so the /// freed name is visible to the insert. async fn upsert_login_user(db: &sqlx::PgPool, info: &UserinfoResponse) -> Result<(), sqlx::Error> { let mut tx = db.begin().await?; mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?; sqlx::query( r" INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (mnw_account_id) DO UPDATE SET username = $2, display_name = $3, avatar_url = $4, is_fan_plus = $5, is_creator = $6, updated_at = now() ", ) .bind(info.user_id) .bind(&info.username) .bind(&info.display_name) .bind(&info.avatar_url) .bind(info.perks.fan_plus) .bind(info.perks.is_creator) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } /// `GET /auth/callback`, exchange code for token, fetch userinfo, create session. #[tracing::instrument(skip_all)] pub async fn callback( State(state): State, session: Session, Query(params): Query, ) -> impl IntoResponse { tracing::info!("OAuth callback received"); // Read and immediately consume the one-time OAuth params. Removing them up // front makes both single-use, so a failed state check, or a replayed // callback, cannot leave a reusable PKCE verifier behind in the session. let stored_state: Option = session.get(SESSION_OAUTH_STATE).await.unwrap_or(None); let stored_verifier: Option = session.get(SESSION_PKCE_VERIFIER).await.unwrap_or(None); if let Err(e) = session.remove::(SESSION_OAUTH_STATE).await { tracing::warn!(error = %e, "failed to remove OAuth state from session"); } if let Err(e) = session.remove::(SESSION_PKCE_VERIFIER).await { tracing::warn!(error = %e, "failed to remove PKCE verifier from session"); } // Verify state nonce in constant time, it's a CSRF token, so compare it on // the same timing-safe path as every other secret (no early-exit on length // or first differing byte). let state_ok = stored_state .as_deref() .is_some_and(|s| crate::csrf::constant_time_compare(s, ¶ms.state)); if !state_ok { tracing::warn!(stored = ?stored_state, received = %params.state, "state mismatch"); return Redirect::to("/?error=state_mismatch"); } // A `prompt=none` silent attempt that couldn't proceed returns an error and // no code (e.g. the MNW session lapsed). Keep the user logged in with their // last-known perks; this is a non-event, not a login failure. if let Some(err) = params.error.as_deref() { tracing::info!(error = %err, "silent re-auth returned without a code"); return Redirect::to("/"); } let code = match params.code.as_deref() { Some(c) => c.to_string(), None => { tracing::warn!("callback missing both code and error"); return Redirect::to("/?error=missing_code"); } }; let verifier: String = match stored_verifier { Some(v) => v, None => { tracing::warn!("missing PKCE verifier in session"); return Redirect::to("/?error=missing_verifier"); } }; // Exchange code for token, then fetch userinfo, each retries on transport/5xx // and returns the `?error=` slug to redirect with on failure. let token = match exchange_code_for_token(&state.http, &state.config, &code, &verifier).await { Ok(t) => t, Err(slug) => return Redirect::to(&format!("/?error={slug}")), }; tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo"); let info = match fetch_userinfo_with_retry( &state.http, &state.config.mnw_base_url, &token.access_token, ) .await { Ok(i) => i, Err(slug) => return Redirect::to(&format!("/?error={slug}")), }; tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful"); if let Err(e) = upsert_login_user(&state.db, &info).await { tracing::error!(error = %e, "user upsert failed"); return Redirect::to("/?error=user_upsert_failed"); } // Check if user is suspended (fail-closed: DB errors block login) let suspended: bool = match sqlx::query_scalar( "SELECT suspended_at IS NOT NULL FROM users WHERE mnw_account_id = $1", ) .bind(info.user_id) .fetch_one(&state.db) .await { Ok(v) => v, Err(e) => { tracing::error!(error = %e, "db error checking suspension status"); return Redirect::to("/?error=internal_error"); } }; if suspended { return Redirect::to("/?error=account_suspended"); } // Save session, perks come from the same userinfo response, no second roundtrip. let session_user = SessionUser { user_id: info.user_id, username: info.username, display_name: info.display_name, perks: info.perks, }; session_user.save_to_session(&session).await; // Store the rotating refresh token (NOT the access token) so future perk // refreshes can mint short-lived access tokens without another OAuth round // trip. The access token was already used for the userinfo fetch above and // is now discarded. A provider that declined offline_access returns no // refresh token; then perk-refresh is unavailable until re-login. if let Some(refresh_token) = token.refresh_token.as_deref() { if let Err(e) = session.insert(SESSION_REFRESH_TOKEN, refresh_token).await { tracing::error!(error = %e, "failed to save refresh token to session"); } } else { tracing::warn!( "token response carried no refresh token; perk refresh disabled this session" ); } if let Err(e) = session.cycle_id().await { tracing::warn!(error = %e, "Failed to cycle session ID"); } tracing::info!("session saved, redirecting to /"); Redirect::to("/") } /// `POST /auth/refresh`, re-fetch MNW userinfo and overwrite cached perks. /// /// Useful after the user takes an action that changed their MNW entitlements /// (e.g., subscribing to Fan+, upgrading a creator tier) so they don't have to /// log out and back in to see the new perks. Returns the refreshed perks as /// JSON. #[tracing::instrument(skip_all)] pub async fn refresh( State(state): State, session: Session, ) -> Result, StatusCode> { match refresh_session(&state, &session).await { Ok(perks) => Ok(Json(RefreshResponse { perks })), // 401 means "perks couldn't be refreshed", NOT logged out. The session // is intact; the frontend can surface a re-link affordance. No flush. Err(UserinfoError::Unauthorized | UserinfoError::RefreshUnavailable) => { Err(StatusCode::UNAUTHORIZED) } Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY), Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY), } } #[derive(Serialize)] pub struct RefreshResponse { pub perks: UserPerks, } /// `POST /auth/logout`, flush session, redirect home. #[tracing::instrument(skip_all)] pub async fn logout(session: Session) -> impl IntoResponse { if let Err(e) = session.flush().await { tracing::warn!(error = %e, "failed to flush session on logout"); } Redirect::to("/") } #[cfg(test)] mod tests { use super::*; #[test] fn pkce_challenge_matches_rfc7636_test_vector() { // RFC 7636 Appendix B known-answer vector. let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; let challenge = pkce_challenge(verifier); assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); } #[test] fn pkce_challenge_is_deterministic() { let v = generate_verifier(); assert_eq!(pkce_challenge(&v), pkce_challenge(&v)); } #[test] fn verifier_is_url_safe_base64_of_32_bytes() { let v = generate_verifier(); // 32 bytes → 43 chars of unpadded base64url. assert_eq!(v.len(), 43); assert!(!v.contains('='), "must be unpadded"); assert!( v.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), "must be url-safe: {v}" ); // And the challenge is likewise url-safe/unpadded (sent as a query param). let c = pkce_challenge(&v); assert_eq!(c.len(), 43); assert!(!c.contains('=')); } #[test] fn verifier_and_nonce_are_unpredictable() { // Sanity that we're not returning a constant. Collisions across 32/16 // random bytes are astronomically unlikely, so equality means a bug. assert_ne!(generate_verifier(), generate_verifier()); assert_ne!(generate_state_nonce(), generate_state_nonce()); } #[test] fn state_nonce_is_128_bits_of_hex() { let n = generate_state_nonce(); assert_eq!(n.len(), 32); // 16 bytes → 32 hex chars assert!(n.bytes().all(|b| b.is_ascii_hexdigit())); } #[test] fn state_comparison_is_constant_time_and_correct() { // The callback compares the returned `state` against the session nonce via // this shared constant-time primitive (auth.rs). Guard the wiring here. assert!(crate::csrf::constant_time_compare("abc123", "abc123")); assert!(!crate::csrf::constant_time_compare("abc123", "abc124")); assert!(!crate::csrf::constant_time_compare("abc", "abc123")); } }