//! OAuth2 authorization server endpoints for "Log in with Makenot.work" //! //! Implements Authorization Code + PKCE (RFC 7636) for desktop/mobile clients. //! //! See also: `/docs/developer/oauth` use crate::csrf::{CsrfRouter, post_csrf_manual, post_csrf_skip}; use axum::{ Form, Json, extract::{FromRequestParts, Query, State}, http::{StatusCode, request::Parts}, response::{IntoResponse, Redirect, Response}, routing::get, }; use rand::Rng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tower_governor::GovernorLayer; use tower_sessions::Session; use sqlx::PgPool; use crate::{ AppState, auth::{MaybeUserVerified, verify_password_async}, config::Config, constants::{self, LOCKOUT_MINUTES}, csrf, db::{self, CreatorTier, SyncAppId, UserId, Username}, error::{AppError, Result}, oauth_scope::{GrantedScopes, OAuthScope}, synckit_auth::{self, OAuthUser, SyncUser}, templates::OAuthAuthorizeTemplate, }; /// Anti-timing dummy hash: ensures the user-not-found path takes the same time /// as the wrong-password path (prevents user enumeration via response timing). static DUMMY_HASH: std::sync::LazyLock = std::sync::LazyLock::new(|| { crate::auth::hash_password("anti-timing-dummy").expect("dummy hash") }); // ── Request/Response types ── #[derive(Deserialize)] pub struct AuthorizeQuery { pub response_type: Option, pub client_id: Option, pub redirect_uri: Option, pub state: Option, pub code_challenge: Option, pub code_challenge_method: Option, /// Space-delimited requested scope. Absent => default userinfo scopes. pub scope: Option, /// OIDC `prompt`. `prompt=none` requests silent re-auth: a code if the MNW /// session is alive, else `error=login_required`, never an interactive page. pub prompt: Option, } #[derive(Deserialize)] pub struct AuthorizeForm { pub client_id: String, pub redirect_uri: String, pub state: String, pub code_challenge: String, pub code_challenge_method: String, #[serde(default)] pub scope: String, pub login: Option, pub password: Option, #[serde(rename = "_csrf")] pub csrf_token: String, } #[derive(Deserialize)] pub struct TokenRequest { pub grant_type: String, pub client_id: String, /// Developer-defined SDK key. Identifies which billing slot this session's /// uploads count against. Required for the authorization_code grant; on a /// refresh the stored key is carried forward. #[serde(default)] pub key: String, // authorization_code grant #[serde(default)] pub code: Option, #[serde(default)] pub redirect_uri: Option, #[serde(default)] pub code_verifier: Option, // refresh_token grant #[serde(default)] pub refresh_token: Option, /// Optional downgrade-only scope on refresh. #[serde(default)] pub scope: Option, } #[derive(Serialize)] pub struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: i64, /// Present only when the grant included `offline_access`. #[serde(skip_serializing_if = "Option::is_none")] pub refresh_token: Option, /// Space-delimited granted scope. pub scope: String, pub user_id: UserId, pub app_id: SyncAppId, } // ── Helpers ── fn generate_oauth_code() -> String { let mut bytes = [0u8; constants::OAUTH_CODE_LENGTH]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } /// Generate an opaque refresh token (returned once, then only its hash is kept). fn generate_refresh_token() -> String { let mut bytes = [0u8; constants::OAUTH_REFRESH_TOKEN_LENGTH]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } /// SHA-256 hex of a refresh token. Tokens are stored and looked up by this hash; /// the plaintext never touches the database. fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } /// Append OAuth response params to a redirect URI, preserving any existing query /// AND fragment. Real URL parsing places the params in the query component, so a /// registered URI carrying a `#fragment` no longer gets `?code=` naively appended /// after the fragment (which corrupts the callback, ultra-fuzz Run 6 R6-UX-1). /// Falls back to separator-concat only if the URI doesn't parse; it is validated /// and registered before reaching here, so that path is defensive. fn build_oauth_redirect(redirect_uri: &str, params: &[(&str, &str)]) -> String { match url::Url::parse(redirect_uri) { Ok(mut url) => { url.query_pairs_mut().extend_pairs(params.iter().copied()); url.into() } Err(_) => { let separator = if redirect_uri.contains('?') { "&" } else { "?" }; let query = params .iter() .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) .collect::>() .join("&"); format!("{redirect_uri}{separator}{query}") } } } /// Build a `redirect_uri?error=...&state=...` response (OIDC error redirect), /// used by `prompt=none` when interaction would otherwise be required. fn redirect_with_error(redirect_uri: &str, state: &str, error_code: &str) -> Response { let url = build_oauth_redirect(redirect_uri, &[("error", error_code), ("state", state)]); Redirect::to(&url).into_response() } /// Persist an authorization code and build the success redirect back to the RP. /// Shared by the interactive POST flow and `prompt=none` silent auth so both /// store scope identically. #[allow(clippy::too_many_arguments)] async fn issue_authorization_code( pool: &sqlx::PgPool, app_id: SyncAppId, user_id: UserId, code_challenge: &str, code_challenge_method: &str, redirect_uri: &str, scope: &GrantedScopes, state_param: &str, ) -> Result { let code = generate_oauth_code(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS); // Store only the hash; the plaintext code goes to the RP in the redirect and // is never persisted (same at-rest contract as refresh tokens). A DB read // therefore can't surface a live, redeemable code. db::oauth::create_oauth_code( pool, &hash_token(&code), app_id, user_id, code_challenge, code_challenge_method, redirect_uri, &scope.to_string(), expires_at, ) .await?; let redirect_url = build_oauth_redirect(redirect_uri, &[("code", &code), ("state", state_param)]); Ok(Redirect::to(&redirect_url).into_response()) } /// Validate that a redirect_uri is allowed. /// /// Localhost callbacks are always permitted. Accepts the three loopback /// forms RFC 8252 §7.3 calls out: /// - `http://127.0.0.1:{port}/...` (IPv4 loopback) /// - `http://[::1]:{port}/...` (IPv6 loopback, bracketed) /// - `http://localhost:{port}/...` (resolver-dependent, included for parity) /// /// Non-localhost URIs must be registered in the app's `redirect_uris` column. fn is_localhost_redirect(uri: &str) -> bool { // Parse strictly rather than prefix-match: require the http scheme, a host // exactly in the loopback set, an explicit non-zero port, and NO embedded // credentials (Run #2 Security MINOR, the old prefix check accepted port 0 // and didn't reject userinfo). The host pin is the load-bearing property: a // native app's loopback listener is the only thing that can receive the code. let Ok(parsed) = url::Url::parse(uri) else { return false; }; if parsed.scheme() != "http" { return false; } if !parsed.username().is_empty() || parsed.password().is_some() { return false; } match parsed.port() { Some(0) | None => return false, Some(_) => {} } matches!( parsed.host_str(), Some("127.0.0.1" | "[::1]" | "::1" | "localhost") ) } async fn validate_redirect_uri( pool: &sqlx::PgPool, app_id: db::SyncAppId, uri: &str, ) -> Result { if is_localhost_redirect(uri) { return Ok(true); } db::oauth::is_registered_redirect_uri(pool, app_id, uri).await } /// Render the authorize page with an error message. fn render_authorize_error( csrf_token: Option, session_user: Option, app_name: &str, form: &AuthorizeForm, error: &str, ) -> Response { OAuthAuthorizeTemplate { csrf_token, session_user, app_name: app_name.to_string(), client_id: form.client_id.clone(), redirect_uri: form.redirect_uri.clone(), state: form.state.clone(), code_challenge: form.code_challenge.clone(), code_challenge_method: form.code_challenge_method.clone(), scope: form.scope.clone(), error_message: Some(error.to_string()), } .into_response() } /// Whether a session is "validated" for OAuth grants: a present, non-suspended /// user (checked by `MaybeUserVerified`) that also carries a tracking ID. /// Legacy sessions predating tracking must re-authenticate via password. async fn has_validated_session(session: &Session) -> bool { session .get::(crate::auth::SESSION_TRACKING_KEY) .await .ok() .flatten() .is_some() } // ── GET /oauth/authorize ── #[tracing::instrument(skip_all, name = "oauth::authorize_get")] async fn authorize_get( State(db): State, MaybeUserVerified(session_user): MaybeUserVerified, session: Session, Query(params): Query, ) -> Result { // Validate required params let response_type = params.response_type.as_deref().unwrap_or(""); if response_type != "code" { return Err(AppError::BadRequest( "response_type must be 'code'".to_string(), )); } let client_id = params .client_id .as_deref() .ok_or_else(|| AppError::BadRequest("client_id is required".to_string()))?; let redirect_uri = params .redirect_uri .as_deref() .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?; let state_param = params .state .as_deref() .ok_or_else(|| AppError::BadRequest("state is required".to_string()))?; // Cap state length on the GET authorize path too (the POST consent path caps // at 1024). state is echoed into the auth_codes row and the redirect URL, so // an unbounded value on the prompt=none branch would otherwise flow through // unchecked. Mirror the POST limit. if state_param.len() > 1024 { return Err(AppError::BadRequest("state is too long".to_string())); } let code_challenge = params .code_challenge .as_deref() .ok_or_else(|| AppError::BadRequest("code_challenge is required".to_string()))?; let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("S256"); if code_challenge_method != "S256" { return Err(AppError::BadRequest( "code_challenge_method must be 'S256'".to_string(), )); } // An S256 challenge is base64url-nopad of a SHA-256: exactly 43 chars (44 // if a stray `=` is included). Reject anything outside that range, // including the empty string from `?code_challenge=`, so the prompt=none // branch below never issues a code bound to an unsatisfiable challenge // (ultra-fuzz Run #1 Security LOW). The POST consent path enforces the same. if !(43..=44).contains(&code_challenge.len()) { return Err(AppError::BadRequest( "code_challenge has invalid length".to_string(), )); } // Look up app by client_id (= sync_apps.api_key) let app = db::synckit::get_sync_app_by_api_key(&db, client_id) .await? .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?; if !validate_redirect_uri(&db, app.id, redirect_uri).await? { return Err(AppError::BadRequest( "redirect_uri is not allowed".to_string(), )); } // Empty scope (no `scope` param) = a legacy sync client; it will receive a // full sync token at /token. A non-empty scope opts into the userinfo flow. let scope = params .scope .as_deref() .map(GrantedScopes::parse) .unwrap_or_default(); // prompt=none: silent re-auth. Issue a code if the MNW session is validated, // otherwise bounce back with error=login_required, never an interactive page. if params.prompt.as_deref() == Some("none") { let validated_session = has_validated_session(&session).await; let validated = session_user.as_ref().filter(|_| validated_session); return match validated { Some(user) => { // Silent re-auth may only mint a code for scopes the user has // ALREADY consented to for this app; anything broader requires // interactive approval (ultra-fuzz Run 6 R6-Sec-L5). The sync // pairing flow (explicit `scope=sync`, or the deprecated empty // scope) is gated by PKCE + interactive pairing rather than the // userinfo consent ledger, so it bypasses the subset check // exactly as empty scope always has. The check reads consent // purely from the DB, so a client-supplied userinfo scope can // never silently widen a grant. let granted = db::oauth::get_granted_scopes(&db, user.id, app.id).await?; if !scope.is_sync_request() && !scope.subset_of(&granted) { return Ok(redirect_with_error( redirect_uri, state_param, "consent_required", )); } issue_authorization_code( &db, app.id, user.id, code_challenge, code_challenge_method, redirect_uri, &scope, state_param, ) .await } None => Ok(redirect_with_error( redirect_uri, state_param, "login_required", )), }; } let csrf_token = csrf::get_or_create_token(&session).await?; Ok(OAuthAuthorizeTemplate { csrf_token: Some(csrf_token), session_user, app_name: app.name, client_id: client_id.to_string(), redirect_uri: redirect_uri.to_string(), state: state_param.to_string(), code_challenge: code_challenge.to_string(), code_challenge_method: code_challenge_method.to_string(), scope: scope.to_string(), error_message: None, } .into_response()) } // ── POST /oauth/authorize ── #[tracing::instrument(skip_all, name = "oauth::authorize_post")] async fn authorize_post( State(db): State, MaybeUserVerified(session_user): MaybeUserVerified, session: Session, Form(form): Form, ) -> Result { // Validate CSRF via the consuming variant, returns the sealed witness // type, so a future refactor that strips the validation call from this // handler fails to compile rather than silently un-gating the mutation. let _validated = csrf::validate_token_consuming(&session, &form.csrf_token).await?; // Cap the size of attacker-controlled fields before they get persisted // (state goes into the auth_codes row + the redirect URL; code_challenge // is fixed-length base64url of a SHA-256). Unbounded `state` lets a // malicious client store arbitrary blobs in the DB through the OAuth flow. if form.state.len() > 1024 { return Err(AppError::BadRequest( "state parameter too long (max 1024 bytes)".to_string(), )); } // S256 challenges are exactly 43 base64url chars (no padding). Allow 44 // for clients that include the trailing `=`. Reject anything outside that // range, including the empty string, as a malformed challenge that would // never verify (ultra-fuzz Run #1 Security LOW: empty was not rejected). if !(43..=44).contains(&form.code_challenge.len()) { return Err(AppError::BadRequest( "code_challenge has invalid length".to_string(), )); } if form.code_challenge_method != "S256" { return Err(AppError::BadRequest( "code_challenge_method must be 'S256'".to_string(), )); } // Look up app let app = db::synckit::get_sync_app_by_api_key(&db, &form.client_id) .await? .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?; if !validate_redirect_uri(&db, app.id, &form.redirect_uri).await? { return Err(AppError::BadRequest("Invalid redirect_uri".to_string())); } let csrf_token = csrf::get_or_create_token(&session).await?; // Session revocation/suspension is checked by MaybeUserVerified at extraction. // For OAuth grants specifically, also require a tracking ID, legacy // sessions predating session tracking must re-authenticate via password. let has_tracking = has_validated_session(&session).await; let validated_session_user = session_user.as_ref().filter(|_| has_tracking); let user_id = if let Some(user) = validated_session_user { // Already logged in via validated MNW session, skip password check user.id } else { // Must authenticate with credentials let login = form.login.as_deref().unwrap_or(""); let password = form.password.as_deref().unwrap_or(""); if login.is_empty() || password.is_empty() { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Username/email and password are required", )); } // Find user by email or username let user = if login.contains('@') { let email = db::Email::new(login) .map_err(|_| AppError::BadRequest("Invalid email".to_string()))?; db::users::get_user_by_email(&db, &email).await? } else { let username = Username::new(login) .map_err(|_| AppError::BadRequest("Invalid username".to_string()))?; db::users::get_user_by_username(&db, &username).await? }; let Some(user) = user else { // Perform a dummy hash verification to prevent timing-based user enumeration let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Invalid username/email or password", )); }; // Check lockout if let Some(locked_until) = user.locked_until && locked_until > chrono::Utc::now() { let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1; return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, &format!("Account is locked. Try again in {remaining} minute(s)."), )); } // Cap password length to prevent DoS via Argon2 on very long inputs. // Same char-count metric as signup (bytes would lock out multibyte // passwords that were accepted at signup). if crate::validation::password_too_long(password) { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Invalid username/email or password", )); } // Verify the password and account status through the shared relying-party // gate. It folds wrong-password / suspended / deactivated / locked / 2FA // into one accounted decision (always increment on denial, reset on // success) so a correct guess against a blocked account is NOT // distinguishable from a wrong one, closing the confirmed-password oracle // that arose from resetting before the status gates (Run 11 Sec M1). The // friendly "already locked" message above still short-circuits before // Argon2; here, only a freshly-tripped lockout earns a distinct notice. match crate::auth::relying_party_login_gate(&db, &user, password).await? { crate::auth::LoginGate::Deny { just_locked } => { let message = if just_locked { format!( "Too many failed attempts. Account locked for {LOCKOUT_MINUTES} minutes." ) } else { "Invalid username/email or password".to_string() }; return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, &message, )); } crate::auth::LoginGate::Allow => {} } user.id }; // Empty scope = legacy sync client; non-empty opts into the userinfo flow. let scope = GrantedScopes::parse(&form.scope); // Record this interactive consent so a later prompt=none re-auth can silently // reuse the approved scopes (R6-Sec-L5). Union with any prior grant. db::oauth::record_granted_scopes(&db, user_id, app.id, &scope).await?; issue_authorization_code( &db, app.id, user_id, &form.code_challenge, &form.code_challenge_method, &form.redirect_uri, &scope, &form.state, ) .await } // ── POST /oauth/token ── /// OAuth error response in the RFC 6749 §5.2 shape (`{"error":"..."}`), 400. fn oauth_error(code: &str) -> Response { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": code })), ) .into_response() } #[tracing::instrument(skip_all, name = "oauth::token_exchange")] async fn token_exchange( State(db): State, State(config): State, axum::Form(req): axum::Form, ) -> Result { let secret = config .synckit_jwt_secret .as_deref() .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?; match req.grant_type.as_str() { "authorization_code" => token_authorization_code(&db, secret, req).await, "refresh_token" => token_refresh(&db, secret, req).await, _ => Err(AppError::BadRequest( "grant_type must be 'authorization_code' or 'refresh_token'".to_string(), )), } } /// Build the success body: a short-lived scoped access token, optionally a fresh /// refresh token (when `offline_access` is granted), and the granted scope. async fn build_token_response( db: &PgPool, secret: &str, user_id: UserId, app_id: SyncAppId, key: &str, scope: &GrantedScopes, ) -> Result { let access_token = synckit_auth::create_oauth_access_token(secret, user_id, app_id, key, scope)?; // Issue the first refresh token in a new chain when offline_access is granted. let refresh_token = if scope.contains(OAuthScope::Offline) { let plaintext = generate_refresh_token(); let chain_id = uuid::Uuid::new_v4(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS); db::oauth::create_refresh_token( db, &hash_token(&plaintext), app_id, user_id, key, &scope.to_string(), chain_id, expires_at, ) .await?; Some(plaintext) } else { None }; Ok(TokenResponse { access_token, token_type: "Bearer".to_string(), expires_in: constants::OAUTH_ACCESS_TOKEN_EXPIRY_SECS, refresh_token, scope: scope.to_string(), user_id, app_id, }) } /// authorization_code grant: verify PKCE, then mint a scoped access token (and a /// refresh token if `offline_access` was granted). async fn token_authorization_code( db: &PgPool, secret: &str, req: TokenRequest, ) -> Result { crate::validation::validate_synckit_key(&req.key)?; let code = req .code .as_deref() .ok_or_else(|| AppError::BadRequest("code is required".to_string()))?; let redirect_uri = req .redirect_uri .as_deref() .ok_or_else(|| AppError::BadRequest("redirect_uri is required".to_string()))?; let code_verifier = req .code_verifier .as_deref() .ok_or_else(|| AppError::BadRequest("code_verifier is required".to_string()))?; // Codes are stored hashed; hash the presented plaintext once and use that for // both the peek and the atomic consume below. let code_hash = hash_token(code); // Peek the code (does NOT consume it) so a failed client_id / redirect_uri // / PKCE check leaves it usable for the legitimate client's retry instead of // burning it (ultra-fuzz Run #1 Security LOW). The atomic consume below is // what actually claims it, so concurrent redemptions stay race-safe. let oauth_code = db::oauth::peek_oauth_code(db, &code_hash) .await? .ok_or(AppError::BadRequest( "Invalid or expired authorization code".to_string(), ))?; let app = db::synckit::get_sync_app_by_api_key(db, &req.client_id) .await? .ok_or(AppError::BadRequest("Unknown client_id".to_string()))?; if app.id != oauth_code.app_id { return Err(AppError::BadRequest("client_id does not match".to_string())); } if redirect_uri != oauth_code.redirect_uri { return Err(AppError::BadRequest( "redirect_uri does not match".to_string(), )); } // Pin S256 (defense in depth, see authorize). if oauth_code.code_challenge_method != "S256" { return Err(AppError::BadRequest( "Unsupported PKCE method on authorization code".to_string(), )); } let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); let digest = hasher.finalize(); let computed_challenge = base64_url_nopad_encode(&digest); if !crate::helpers::constant_time_compare(&computed_challenge, &oauth_code.code_challenge) { return Err(AppError::BadRequest("PKCE verification failed".to_string())); } // All checks passed, now atomically claim the code. A None here means a // concurrent request already redeemed it (or it expired in the gap); the // `used_at IS NULL` guard makes double-redemption impossible. let oauth_code = db::oauth::consume_oauth_code(db, &code_hash) .await? .ok_or(AppError::BadRequest( "Invalid or expired authorization code".to_string(), ))?; // Re-check account liveness at redemption. A user suspended or deactivated // between authorize and code->token must not receive a token; the refresh // grant already applies this gate, but the code->token path skipped it, // leaving a window (up to the code TTL) where a just-suspended user could // still mint a ~1h sync token. Mirror the refresh path's liveness check. match db::users::get_user_by_id(db, oauth_code.user_id).await? { Some(u) if !(u.is_suspended() || u.is_deactivated()) => {} _ => return Ok(oauth_error("invalid_grant")), } let scope = GrantedScopes::parse(&oauth_code.scope); // Only an explicit `scope=sync` mints the full 7-day sync token, the sole // path that issues a sync-API-capable token from /oauth/token. An omitted or // unrecognized scope now falls through to the least-privilege userinfo path // below rather than being escalated to sync (audit Run 17 Security). if scope.is_sync_request() { let token = synckit_auth::create_sync_token( secret, oauth_code.user_id, oauth_code.app_id, &req.key, )?; return Ok(Json(TokenResponse { access_token: token, token_type: "Bearer".to_string(), expires_in: constants::SYNCKIT_JWT_EXPIRY_SECS, refresh_token: None, scope: String::new(), user_id: oauth_code.user_id, app_id: oauth_code.app_id, }) .into_response()); } // Scoped request = the userinfo RP flow: short-lived userinfo token, plus a // refresh token when offline_access was granted. let resp = build_token_response( db, secret, oauth_code.user_id, oauth_code.app_id, &req.key, &scope, ) .await?; Ok(Json(resp).into_response()) } /// refresh_token grant: rotate the presented token (reuse-detected), re-check /// revocation/liveness, enforce downgrade-only scope, and mint a fresh pair. async fn token_refresh(db: &PgPool, secret: &str, req: TokenRequest) -> Result { let presented = match req.refresh_token.as_deref() { Some(t) if !t.is_empty() => t, _ => return Ok(oauth_error("invalid_request")), }; let consumed = match db::oauth::rotate_refresh_token(db, &hash_token(presented)).await? { db::oauth::RefreshRotateOutcome::Valid(row) => row, db::oauth::RefreshRotateOutcome::Reused { chain_id } => { // Theft signal: a rotated token was presented again. Kill the chain. db::oauth::revoke_refresh_chain(db, chain_id).await?; return Ok(oauth_error("invalid_grant")); } db::oauth::RefreshRotateOutcome::Invalid => return Ok(oauth_error("invalid_grant")), }; // Bind the grant to its client: the presented client_id must own this refresh // lineage. The auth-code path enforces this; the refresh path did not, so a // stolen refresh token was redeemable under any client_id (Run #2 Security // MINOR). The token is already rotated above, so a mismatch leaves the stolen // token spent and the legit client's next refresh trips reuse-detection. let client_app = db::synckit::get_sync_app_by_api_key(db, &req.client_id).await?; if client_app.map(|a| a.id) != Some(consumed.app_id) { return Ok(oauth_error("invalid_grant")); } // Revocation + liveness via the one shared gate the token extractors use, so // app-deactivate / suspend / password change AND sync-device removal all // kill the refresh lineage (the M-Sec1 parity fix: this path previously // skipped `sync_jwt_invalidated_at`). A liveness failure (Unauthorized) // revokes the chain and denies; a real infrastructure error propagates as // 5xx without touching the chain. match synckit_auth::assert_token_live( db, consumed.app_id, consumed.user_id, consumed.issued_after.timestamp(), ) .await { Ok(()) => {} Err(AppError::Unauthorized) => { db::oauth::revoke_refresh_chain(db, consumed.chain_id).await?; return Ok(oauth_error("invalid_grant")); } Err(e) => return Err(e), } // Downgrade-only scope: a refresh may narrow but never widen. let stored = GrantedScopes::parse(&consumed.scope); let granted = match req.scope.as_deref() { Some(s) if !s.trim().is_empty() => { let requested = GrantedScopes::parse(s); if !requested.subset_of(&stored) { return Ok(oauth_error("invalid_scope")); } requested } _ => stored, }; let access_token = synckit_auth::create_oauth_access_token( secret, consumed.user_id, consumed.app_id, &consumed.key, &granted, )?; // Rotate: a new refresh token in the SAME chain, when offline_access stays. let refresh_token = if granted.contains(OAuthScope::Offline) { let plaintext = generate_refresh_token(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_REFRESH_TOKEN_EXPIRY_SECS); db::oauth::create_refresh_token( db, &hash_token(&plaintext), consumed.app_id, consumed.user_id, &consumed.key, &granted.to_string(), consumed.chain_id, expires_at, ) .await?; Some(plaintext) } else { None }; Ok(Json(TokenResponse { access_token, token_type: "Bearer".to_string(), expires_in: constants::OAUTH_ACCESS_TOKEN_EXPIRY_SECS, refresh_token, scope: granted.to_string(), user_id: consumed.user_id, app_id: consumed.app_id, }) .into_response()) } /// URL-safe base64 encoding without padding (RFC 4648 Section 5). fn base64_url_nopad_encode(data: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) } // ── GET /oauth/userinfo ── // // Canonical "what is this user entitled to on MNW" endpoint for external // implementers of "Log in with MNW". Always returns fresh state from the // database, implementers cache client-side and pull-refresh on demand. // // The `perks` object is the extension point: new capabilities are added here // (and to `CreatorTier::features`) as they ship. See `docs/oauth_integration.md`. #[derive(Serialize)] struct UserPerks { /// Active Fan+ consumer subscription. fan_plus: bool, /// Has an active creator subscription at any tier. is_creator: bool, /// Structured creator tier info, present when `is_creator` is true. creator_tier: Option, } #[derive(Serialize)] struct CreatorTierInfo { tier: CreatorTier, features: &'static [&'static str], } /// The principal calling `/oauth/userinfo`. Accepts the new userinfo-scoped /// token (the secure path) and, for backward-compatibility during MT's /// migration, the legacy full sync token, treated as holding every scope. /// This is the ONLY place the legacy token remains accepted; the sync API is /// unchanged and still rejects userinfo-aud tokens. enum UserinfoPrincipal { Oauth(OAuthUser), Legacy(SyncUser), } impl FromRequestParts for UserinfoPrincipal { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> std::result::Result { if let Ok(u) = OAuthUser::from_request_parts(parts, state).await { return Ok(UserinfoPrincipal::Oauth(u)); } let sync = SyncUser::from_request_parts(parts, state).await?; Ok(UserinfoPrincipal::Legacy(sync)) } } #[tracing::instrument(skip_all, name = "oauth::userinfo")] async fn userinfo( State(db): State, principal: std::result::Result, ) -> impl IntoResponse { // (user_id, may read identity, may read perks). Legacy sync token => all. let (user_id, profile_ok, perks_ok) = match principal { Ok(UserinfoPrincipal::Oauth(u)) => ( u.user_id, u.scopes.contains(OAuthScope::ProfileRead), u.scopes.contains(OAuthScope::PerksRead), ), Ok(UserinfoPrincipal::Legacy(s)) => (s.user_id, true, true), Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_token"})), ) .into_response(); } }; if !profile_ok && !perks_ok { return ( StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "insufficient_scope"})), ) .into_response(); } let Ok(Some(db_user)) = db::users::get_user_by_id(&db, user_id).await else { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "user_not_found"})), ) .into_response(); }; // user_id (the subject) is always returned; identity and perks are gated. let mut body = serde_json::Map::new(); body.insert("user_id".to_string(), serde_json::json!(db_user.id)); if profile_ok { body.insert( "username".to_string(), serde_json::json!(db_user.username.to_string()), ); body.insert( "display_name".to_string(), serde_json::json!(db_user.display_name), ); body.insert( "avatar_url".to_string(), serde_json::json!(db_user.avatar_url), ); } if perks_ok { let fan_plus = db::fan_plus::is_fan_plus_active(&db, db_user.id) .await .unwrap_or(false); let creator_tier = db_user .creator_tier .as_deref() .and_then(|s| s.parse::().ok()); let perks = UserPerks { fan_plus, is_creator: creator_tier.is_some(), creator_tier: creator_tier.map(|tier| CreatorTierInfo { tier, features: tier.features(), }), }; body.insert("perks".to_string(), serde_json::json!(perks)); } Json(serde_json::Value::Object(body)).into_response() } // ── GET /.well-known/oauth-authorization-server (RFC 8414) ── #[tracing::instrument(skip_all, name = "oauth::discovery")] async fn discovery_metadata(State(config): State) -> impl IntoResponse { let base = config.host_url.trim_end_matches('/'); Json(serde_json::json!({ "issuer": base, "authorization_endpoint": format!("{base}/oauth/authorize"), "token_endpoint": format!("{base}/oauth/token"), "userinfo_endpoint": format!("{base}/oauth/userinfo"), "scopes_supported": ["profile:read", "perks:read", "offline_access"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"], })) } // ── Router ── pub fn oauth_routes() -> CsrfRouter { let authorize_rate_limit = crate::helpers::rate_limiter_ms( constants::OAUTH_RATE_LIMIT_MS, constants::OAUTH_RATE_LIMIT_BURST, ); let token_rate_limit = crate::helpers::rate_limiter_ms( constants::OAUTH_TOKEN_RATE_LIMIT_MS, constants::OAUTH_TOKEN_RATE_LIMIT_BURST, ); let authorize_routes = CsrfRouter::new() .route_get("/oauth/authorize", get(authorize_get)) .route("/oauth/authorize", post_csrf_manual("OAuth authorize validates the consent form _csrf in-handler via validate_token_consuming", authorize_post)) .route_layer(GovernorLayer::new(authorize_rate_limit)); let token_routes = CsrfRouter::new() .route( "/oauth/token", post_csrf_skip("pre-auth OAuth token exchange", token_exchange), ) .route_layer(GovernorLayer::new(token_rate_limit)); // userinfo is DB-amplifying (user + creator-tier lookup) and discovery is a // public read; govern both so every public OAuth route carries a rate limit // (SEC-S3, Run #23, they were previously merged in ungoverned). let read_rate_limit = crate::helpers::rate_limiter_ms( constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST, ); let read_routes = CsrfRouter::new() .route_get("/oauth/userinfo", get(userinfo)) .route_get( "/.well-known/oauth-authorization-server", get(discovery_metadata), ) .route_layer(GovernorLayer::new(read_rate_limit)); authorize_routes.merge(token_routes).merge(read_routes) } #[cfg(test)] mod tests { use super::build_oauth_redirect; #[test] fn appends_query_to_plain_uri() { let url = build_oauth_redirect( "https://app.example/cb", &[("code", "abc"), ("state", "s1")], ); assert_eq!(url, "https://app.example/cb?code=abc&state=s1"); } #[test] fn merges_with_existing_query() { let url = build_oauth_redirect("https://app.example/cb?foo=bar", &[("code", "abc")]); assert_eq!(url, "https://app.example/cb?foo=bar&code=abc"); } #[test] fn preserves_fragment_and_keeps_query_before_it() { // R6-UX-1: the naive contains('?') builder appended ?code= AFTER the // fragment, corrupting the callback. Real URL parsing keeps the query in // its own component, ahead of the fragment. let url = build_oauth_redirect( "https://app.example/cb#frag", &[("code", "abc"), ("state", "s1")], ); assert_eq!(url, "https://app.example/cb?code=abc&state=s1#frag"); } #[test] fn percent_encodes_values() { let url = build_oauth_redirect("https://app.example/cb", &[("error", "consent required")]); assert!(url.contains("error=consent+required") || url.contains("error=consent%20required")); } #[test] fn loopback_callback_gets_query() { let url = build_oauth_redirect( "http://127.0.0.1:9999/callback", &[("code", "xyz"), ("state", "s")], ); assert_eq!(url, "http://127.0.0.1:9999/callback?code=xyz&state=s"); } }