//! 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 axum::{ extract::{Query, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, routing::get, Form, Json, }; use crate::csrf::{post_csrf_skip, CsrfRouter}; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tower_governor::GovernorLayer; use tower_sessions::Session; use crate::{ auth::{verify_password, MaybeUserVerified}, constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS}, csrf, db::{self, CreatorTier, SyncAppId, UserId, Username}, error::{AppError, Result}, synckit_auth, templates::OAuthAuthorizeTemplate, AppState, }; /// 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, } #[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, pub login: Option, pub password: Option, #[serde(rename = "_csrf")] pub csrf_token: String, } #[derive(Deserialize)] pub struct TokenRequest { pub grant_type: String, pub code: String, pub redirect_uri: String, pub code_verifier: String, pub client_id: String, /// Developer-defined SDK key. Identifies which billing slot this session's /// uploads count against. Required. pub key: String, } #[derive(Serialize)] pub struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: i64, 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) } /// 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 { for prefix in ["http://127.0.0.1:", "http://[::1]:", "http://localhost:"] { if let Some(rest) = uri.strip_prefix(prefix) && let Some(port_str) = rest.split('/').next() && port_str.parse::().is_ok() { return true; } } false } 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(), error_message: Some(error.to_string()), } .into_response() } // ── GET /oauth/authorize ── #[tracing::instrument(skip_all, name = "oauth::authorize_get")] async fn authorize_get( State(state): 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()))?; 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())); } // Look up app by client_id (= sync_apps.api_key) let app = db::synckit::get_sync_app_by_api_key(&state.db, client_id) .await? .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?; if !validate_redirect_uri(&state.db, app.id, redirect_uri).await? { return Err(AppError::BadRequest("redirect_uri is not allowed".to_string())); } 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(), error_message: None, } .into_response()) } // ── POST /oauth/authorize ── #[tracing::instrument(skip_all, name = "oauth::authorize_post")] async fn authorize_post( State(state): 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 `=`. Anything wildly larger is a // malformed challenge that would never verify. if form.code_challenge.len() > 44 { 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(&state.db, &form.client_id) .await? .ok_or_else(|| AppError::BadRequest("Unknown client_id".to_string()))?; if !validate_redirect_uri(&state.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 = session .get::(crate::auth::SESSION_TRACKING_KEY) .await .ok() .flatten() .is_some(); 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(&state.db, &email).await? } else { let username = Username::new(login).map_err(|_| AppError::BadRequest("Invalid username".to_string()))?; db::users::get_user_by_username(&state.db, &username).await? }; let user = match user { Some(u) => u, None => { // Perform a dummy hash verification to prevent timing-based user enumeration let _ = verify_password("dummy", &DUMMY_HASH); 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 {} minute(s).", remaining), )); } // Cap password length to prevent DoS via Argon2 on very long inputs if password.len() > 128 { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Invalid username/email or password", )); } // Verify password if !verify_password(password, &user.password_hash)? { let result = db::auth::increment_failed_login( &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES, ).await?; if result.just_locked { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, &format!( "Too many failed attempts. Account locked for {} minutes.", LOCKOUT_MINUTES ), )); } return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Invalid username/email or password", )); } // Successful auth — reset failed attempts db::auth::reset_failed_login(&state.db, user.id).await?; // Block suspended or deactivated users if user.is_suspended() || user.is_deactivated() { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "This account is not active.", )); } // If user has TOTP 2FA enabled, reject — they must log in via the main site first if user.totp_enabled { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "This account has two-factor authentication enabled. Please log in at makenot.work first, then return here to authorize the app.", )); } user.id }; // Generate authorization code let code = generate_oauth_code(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(constants::OAUTH_CODE_EXPIRY_SECS); db::oauth::create_oauth_code( &state.db, &code, app.id, user_id, &form.code_challenge, &form.code_challenge_method, &form.redirect_uri, expires_at, ) .await?; // Redirect back to the app's callback with URL-encoded parameters let separator = if form.redirect_uri.contains('?') { "&" } else { "?" }; let redirect_url = format!( "{}{}code={}&state={}", form.redirect_uri, separator, urlencoding::encode(&code), urlencoding::encode(&form.state), ); Ok(Redirect::to(&redirect_url).into_response()) } // ── POST /oauth/token ── #[tracing::instrument(skip_all, name = "oauth::token_exchange")] async fn token_exchange( State(state): State, axum::Form(req): axum::Form, ) -> Result { if req.grant_type != "authorization_code" { return Err(AppError::BadRequest("grant_type must be 'authorization_code'".to_string())); } crate::validation::validate_synckit_key(&req.key)?; let secret = state .config .synckit_jwt_secret .as_deref() .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?; // Atomically consume code (must exist, not expired, not used). // Single UPDATE...RETURNING prevents TOCTOU race on concurrent requests. let oauth_code = db::oauth::consume_oauth_code(&state.db, &req.code) .await? .ok_or(AppError::BadRequest("Invalid or expired authorization code".to_string()))?; // Verify client_id matches the app that the code was issued for let app = db::synckit::get_sync_app_by_api_key(&state.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())); } // Verify redirect_uri matches exactly if req.redirect_uri != oauth_code.redirect_uri { return Err(AppError::BadRequest("redirect_uri does not match".to_string())); } // Verify PKCE method matches what the authorize step recorded. We only // accept S256 at authorize time, but pinning it here too means a future // change that loosens the authorize check can't silently downgrade // verification to `plain` (where code_verifier == code_challenge and // the SHA-256 step below would never run). Defense in depth. if oauth_code.code_challenge_method != "S256" { return Err(AppError::BadRequest( "Unsupported PKCE method on authorization code".to_string(), )); } // Verify PKCE: SHA256(code_verifier) must equal stored code_challenge // code_challenge is URL-safe base64 no-pad of SHA256(verifier) let mut hasher = Sha256::new(); hasher.update(req.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())); } let token = synckit_auth::create_sync_token( secret, oauth_code.user_id, oauth_code.app_id, &req.key, )?; Ok(Json(TokenResponse { access_token: token, token_type: "Bearer".to_string(), expires_in: constants::SYNCKIT_JWT_EXPIRY_SECS, user_id: oauth_code.user_id, app_id: oauth_code.app_id, })) } /// 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 UserinfoResponse { user_id: UserId, username: String, display_name: Option, avatar_url: Option, perks: UserPerks, } #[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], } #[tracing::instrument(skip_all, name = "oauth::userinfo")] async fn userinfo( State(state): State, user: std::result::Result, ) -> impl IntoResponse { let user = match user { Ok(u) => u, Err(_) => { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_token"})), ).into_response(); } }; let db_user = match db::users::get_user_by_id(&state.db, user.user_id).await { Ok(Some(u)) => u, _ => { return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "user_not_found"})), ).into_response(); } }; let fan_plus = db::fan_plus::is_fan_plus_active(&state.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(), }), }; Json(UserinfoResponse { user_id: db_user.id, username: db_user.username.to_string(), display_name: db_user.display_name, avatar_url: db_user.avatar_url, perks, }).into_response() } // ── 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_skip("pre-auth OAuth authorize endpoint", authorize_post)) .route_layer(GovernorLayer { config: authorize_rate_limit, }); let token_routes = CsrfRouter::new() .route("/oauth/token", post_csrf_skip("pre-auth OAuth token exchange", token_exchange)) .route_layer(GovernorLayer { config: token_rate_limit, }); authorize_routes .merge(token_routes) .route_get("/oauth/userinfo", get(userinfo)) }