//! Passport 13.7.6 browser client administration and authorization-code consent. //! Consent requests are held server-side and bound to the authenticated browser //! session. Browser input can never choose the user, client, redirect or scopes //! when approving an already validated request. use crate::{ApiError, AppState, auth::AuthUser}; use axum::{ Extension, Json, Router, body::Bytes, extract::{Path, Query, State}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Response}, routing::{delete, get, put}, }; use chrono::Utc; use rand::{Rng, distributions::Alphanumeric}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::{Row, SqlitePool, sqlite::SqliteRow}; use std::collections::HashMap; use subtle::ConstantTimeEq; const OWNER: &str = "App\\Models\\User"; pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_oauth_authorizations(session_id TEXT PRIMARY KEY,user_id INTEGER NOT NULL,auth_token TEXT NOT NULL,request TEXT NOT NULL,expires_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS rust_oauth_login_prompts(id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,expires_at INTEGER NOT NULL);").execute(db).await?; Ok(()) } /// The pinned app enables Passport's JSON routes after Passport already boots. /// Keep the actual SPA/405 defaults; explicit opt-in exposes corrected v13 /// owner-based administration. Its deprecated upstream repository uses user_id. pub fn routes(state: AppState) -> Router { routes_with_options( state, std::env::var("PASSPORT_JSON_API_ROUTES").is_ok_and(|v| v == "true" || v == "1"), std::env::var("RUST_OAUTH_CONSENT_ENABLED").is_ok_and(|v| v == "true" || v == "1"), ) } pub fn routes_with_options(state: AppState, json_routes: bool, consent: bool) -> Router { // Refresh and consent approval routes are outside Passport's JSON gate. let mut protected = Router::new() .route("/oauth/token/refresh", axum::routing::post(refresh_cookie)) .route( "/oauth/authorize", axum::routing::post(approve).delete(deny), ); if json_routes { protected = protected .route("/oauth/clients", get(list_clients).post(create_client)) .route( "/oauth/clients/{client_id}", put(update_client).delete(delete_client), ) .route("/oauth/tokens", get(list_tokens)) .route("/oauth/tokens/{token_id}", delete(delete_token)); } protected = protected.route_layer(axum::middleware::from_fn_with_state( state, crate::auth::require_web_auth, )); let authorization = if consent { Router::new().route("/oauth/authorize", get(authorize)) } else { Router::new().route( "/oauth/authorize", get(|| async { ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error") }), ) }; protected.merge(authorization) } fn random(length: usize) -> String { rand::thread_rng() .sample_iter(&Alphanumeric) .take(length) .map(char::from) .collect() } fn date(row: &SqliteRow, name: &str) -> Value { row.get::, _>(name) .map(|v| { chrono::NaiveDateTime::parse_from_str(&v, "%Y-%m-%d %H:%M:%S") .map(|d| json!(d.and_utc().format("%Y-%m-%dT%H:%M:%S.000000Z").to_string())) .unwrap_or(json!(v)) }) .unwrap_or(Value::Null) } fn client_json(row: &SqliteRow) -> Value { json!({"id":row.get::("id"),"owner_id":row.get::,_>("owner_id"),"owner_type":row.get::,_>("owner_type"),"name":row.get::("name"),"provider":row.get::,_>("provider"),"redirect_uris":serde_json::from_str::(&row.get::("redirect_uris")).unwrap_or(json!([])),"grant_types":serde_json::from_str::(&row.get::("grant_types")).unwrap_or(json!([])),"revoked":row.get::("revoked"),"created_at":date(row,"created_at"),"updated_at":date(row,"updated_at")}) } fn token_json(row: &SqliteRow) -> Value { json!({"id":row.get::("id"),"user_id":row.get::,_>("user_id"),"client_id":row.get::("client_id"),"name":row.get::,_>("name"),"scopes":serde_json::from_str::(&row.get::,_>("scopes").unwrap_or_else(||"[]".into())).unwrap_or(json!([])),"revoked":row.get::("revoked"),"created_at":date(row,"created_at"),"updated_at":date(row,"updated_at"),"expires_at":date(row,"expires_at")}) } fn input(headers: &HeaderMap, body: &[u8]) -> Result { if headers .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) .is_some_and(|v| v.split(';').next() == Some("application/x-www-form-urlencoded")) { let values: HashMap = serde_urlencoded::from_bytes(body) .map_err(|_| ApiError(StatusCode::UNPROCESSABLE_ENTITY, "Invalid request."))?; Ok(json!(values)) } else { serde_json::from_slice(body) .map_err(|_| ApiError(StatusCode::UNPROCESSABLE_ENTITY, "Invalid request.")) } } fn validation_errors(input: &Value, confidential: bool) -> Value { let mut errors = serde_json::Map::new(); match input["name"].as_str() { None => { errors.insert("name".into(), json!(["The name field is required."])); } Some(v) if v.trim().is_empty() => { errors.insert("name".into(), json!(["The name field is required."])); } Some(v) if v.chars().count() > 255 => { errors.insert( "name".into(), json!(["The name field must not be greater than 255 characters."]), ); } _ => {} } match input["redirect"].as_str() { None | Some("") => { errors.insert( "redirect".into(), json!(["The redirect field is required."]), ); } Some(v) if v.split(',').any(|u| !valid_uri(u)) => { errors.insert( "redirect".into(), json!(["One or more redirects have an invalid URI format."]), ); } _ => {} } if confidential && input .get("confidential") .is_some_and(|v| bool_value(v).is_none()) { errors.insert( "confidential".into(), json!(["The confidential field must be true or false."]), ); } Value::Object(errors) } fn bool_value(v: &Value) -> Option { match v { Value::Bool(v) => Some(*v), Value::Number(v) if v.as_i64() == Some(0) => Some(false), Value::Number(v) if v.as_i64() == Some(1) => Some(true), Value::String(v) if v == "0" => Some(false), Value::String(v) if v == "1" => Some(true), _ => None, } } fn valid_uri(s: &str) -> bool { !s.chars().any(|c| c.is_whitespace() || c.is_control()) && url::Url::parse(s).is_ok_and(|u| u.host_str().is_some() && !u.scheme().is_empty()) } fn validation_response(errors: Value) -> Response { let message = errors .as_object() .and_then(|o| o.values().next()) .and_then(|a| a[0].as_str()) .unwrap_or("The given data was invalid."); ( StatusCode::UNPROCESSABLE_ENTITY, Json(json!({"message":message,"errors":errors})), ) .into_response() } async fn owned(db: &SqlitePool, id: &str, user: i64) -> Result, sqlx::Error> { sqlx::query("SELECT * FROM oauth_clients WHERE CAST(id AS TEXT)=? AND owner_id=? AND owner_type=? AND revoked=0").bind(id).bind(user).bind(OWNER).fetch_optional(db).await } async fn list_clients( State(state): State, Extension(user): Extension, ) -> Result, ApiError> { let rows = sqlx::query( "SELECT * FROM oauth_clients WHERE owner_id=? AND owner_type=? AND revoked=0 ORDER BY name", ) .bind(user.id) .bind(OWNER) .fetch_all(&state.db) .await?; Ok(Json(json!( rows.iter().map(client_json).collect::>() ))) } async fn create_client( State(state): State, Extension(user): Extension, headers: HeaderMap, body: Bytes, ) -> Result { let i = input(&headers, &body)?; let errors = validation_errors(&i, true); if !errors.as_object().unwrap().is_empty() { return Ok(validation_response(errors)); } let secret = if i.get("confidential").and_then(bool_value).unwrap_or(true) { Some(random(40)) } else { None }; let hashed = secret .as_ref() .map(|s| bcrypt::hash(s, bcrypt::DEFAULT_COST)) .transpose() .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?; let redirects = i["redirect"] .as_str() .unwrap() .split(',') .collect::>(); let r=sqlx::query("INSERT INTO oauth_clients(owner_id,owner_type,name,secret,provider,redirect_uris,grant_types,revoked,created_at,updated_at) VALUES(?,?,?,?,NULL,?,'[\"authorization_code\",\"refresh_token\"]',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(user.id).bind(OWNER).bind(i["name"].as_str().unwrap().trim()).bind(hashed).bind(json!(redirects).to_string()).execute(&state.db).await?; let row = sqlx::query("SELECT * FROM oauth_clients WHERE id=?") .bind(r.last_insert_rowid()) .fetch_one(&state.db) .await?; let mut value = client_json(&row); if let Some(secret) = secret { value["plain_secret"] = json!(secret); } Ok(Json(value).into_response()) } async fn update_client( State(state): State, Extension(user): Extension, Path(id): Path, headers: HeaderMap, body: Bytes, ) -> Result { if owned(&state.db, &id, user.id).await?.is_none() { return Ok(StatusCode::NOT_FOUND.into_response()); } let i = input(&headers, &body)?; let errors = validation_errors(&i, false); if !errors.as_object().unwrap().is_empty() { return Ok(validation_response(errors)); } sqlx::query("UPDATE oauth_clients SET name=?,redirect_uris=?,updated_at=CURRENT_TIMESTAMP WHERE CAST(id AS TEXT)=? AND owner_id=? AND owner_type=? AND revoked=0").bind(i["name"].as_str().unwrap().trim()).bind(json!(i["redirect"].as_str().unwrap().split(',').collect::>()).to_string()).bind(&id).bind(user.id).bind(OWNER).execute(&state.db).await?; Ok(Json(client_json( &owned(&state.db, &id, user.id) .await? .ok_or(ApiError(StatusCode::NOT_FOUND, "Not Found"))?, )) .into_response()) } async fn delete_client( State(state): State, Extension(user): Extension, Path(id): Path, ) -> Result { let mut tx = state.db.begin().await?; let n=sqlx::query("UPDATE oauth_clients SET revoked=1,updated_at=CURRENT_TIMESTAMP WHERE CAST(id AS TEXT)=? AND owner_id=? AND owner_type=? AND revoked=0").bind(&id).bind(user.id).bind(OWNER).execute(&mut *tx).await?.rows_affected(); if n == 0 { return Ok(StatusCode::NOT_FOUND); } sqlx::query("UPDATE oauth_refresh_tokens SET revoked=1 WHERE access_token_id IN (SELECT id FROM oauth_access_tokens WHERE CAST(client_id AS TEXT)=?)").bind(&id).execute(&mut *tx).await?; sqlx::query("UPDATE oauth_access_tokens SET revoked=1 WHERE CAST(client_id AS TEXT)=?") .bind(&id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT) } async fn list_tokens( State(state): State, Extension(user): Extension, ) -> Result, ApiError> { let rows=sqlx::query("SELECT t.* FROM oauth_access_tokens t JOIN oauth_clients c ON c.id=t.client_id WHERE t.user_id=? AND t.revoked=0 AND datetime(t.expires_at)>datetime('now') AND c.revoked=0 AND c.owner_id IS NOT NULL AND c.owner_id!=0").bind(user.id).fetch_all(&state.db).await?; let mut result = Vec::new(); for row in rows { let mut v = token_json(&row); let c = sqlx::query("SELECT * FROM oauth_clients WHERE id=?") .bind(row.get::("client_id")) .fetch_one(&state.db) .await?; v["client"] = client_json(&c); result.push(v); } Ok(Json(json!(result))) } async fn delete_token( State(state): State, Extension(user): Extension, Path(id): Path, ) -> Result { let mut tx = state.db.begin().await?; let n=sqlx::query("UPDATE oauth_access_tokens SET revoked=1 WHERE id=? AND user_id=? AND revoked=0 AND datetime(expires_at)>datetime('now')").bind(&id).bind(user.id).execute(&mut *tx).await?.rows_affected(); if n == 0 { return Ok(StatusCode::NOT_FOUND); } sqlx::query("UPDATE oauth_refresh_tokens SET revoked=1 WHERE access_token_id=?") .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT) } async fn refresh_cookie( State(state): State, Extension(user): Extension, headers: HeaderMap, ) -> Result { let csrf = crate::auth::browser_csrf_token(&state, &headers) .await? .ok_or(ApiError(StatusCode::UNAUTHORIZED, "Unauthenticated."))?; let cookie = crate::passport::fresh_cookie_value(&state, user.id, &csrf)?; let path = std::env::var("SESSION_PATH").unwrap_or_else(|_| "/".into()); let domain = std::env::var("SESSION_DOMAIN") .ok() .filter(|v| !v.is_empty() && v != "null"); let secure = std::env::var("SESSION_SECURE_COOKIE").is_ok_and(|v| v == "true" || v == "1"); let age = crate::auth::TTL; let expiry = (Utc::now() + chrono::Duration::seconds(age)) .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let mut value = format!( "laravel_token={}; expires={}; Max-Age={}; path={}; HttpOnly; SameSite=Lax", percent_encoding::utf8_percent_encode(&cookie, percent_encoding::NON_ALPHANUMERIC), expiry, age, path ); if let Some(domain) = domain { value.push_str(&format!("; domain={domain}")); } if secure { value.push_str("; Secure"); } let mut r = "Refreshed.".into_response(); r.headers_mut().insert( header::CONTENT_TYPE, header::HeaderValue::from_static("text/html; charset=UTF-8"), ); r.headers_mut().append( header::SET_COOKIE, header::HeaderValue::from_str(&value) .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?, ); Ok(r) } #[derive(Debug, Serialize, Deserialize)] struct Authorization { client_id: String, redirect_uri: String, #[serde(default)] requested_redirect_uri: Option, #[serde(default)] scopes: Vec, state: Option, code_challenge: Option, code_challenge_method: Option, } fn error_payload(error: &str, hint: &str) -> Value { let description = match error { "invalid_request" => { "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." } "invalid_client" => "Client authentication failed", "invalid_scope" => "The requested scope is invalid, unknown, or malformed", "unauthorized_client" => { "The authenticated client is not authorized to use this authorization grant type." } "consent_required" => "The authorization server requires end-user consent.", _ => hint, }; let mut payload = json!({"error":error,"error_description":description}); match error { "invalid_client" | "consent_required" => {} "login_required" => payload["hint"] = json!("The user is not authenticated"), "access_denied" => payload["hint"] = json!("The user denied the request"), _ => payload["hint"] = json!(hint), } payload } fn oauth_error(error: &str, hint: &str) -> Response { ( if error == "invalid_client" { StatusCode::UNAUTHORIZED } else { StatusCode::BAD_REQUEST }, Json(error_payload(error, hint)), ) .into_response() } fn redirect(uri: &str, params: &[(&str, &str)]) -> Response { match url::Url::parse(uri) { Ok(mut u) => { u.query_pairs_mut().extend_pairs(params.iter().copied()); (StatusCode::FOUND, [(header::LOCATION, u.as_str())]).into_response() } Err(_) => oauth_error("invalid_request", "The redirect URI is invalid."), } } fn redirect_error(a: &Authorization, error: &str, description: &str) -> Response { let payload = error_payload(error, description); let mut p = payload .as_object() .unwrap() .iter() .filter_map(|(key, value)| value.as_str().map(|value| (key.as_str(), value))) .collect::>(); if let Some(s) = a.state.as_deref() { p.push(("state", s)); } redirect(&a.redirect_uri, &p) } fn validate_authorization( q: &HashMap, c: &SqliteRow, ) -> Result> { let grants: Vec = serde_json::from_str(&c.get::("grant_types")).unwrap_or_default(); if !grants.iter().any(|g| g == "authorization_code") || c.get::, _>("provider") .is_some_and(|p| p != "users") { return Err(Box::new(oauth_error( "unauthorized_client", "The client is not authorized to request an authorization code using this method.", ))); } let uris: Vec = serde_json::from_str(&c.get::("redirect_uris")).unwrap_or_default(); let uri = match q.get("redirect_uri") { Some(uri) if uris.contains(uri) => uri.clone(), None if uris.len() == 1 => uris[0].clone(), _ => { return Err(Box::new(oauth_error( "invalid_request", "The redirect URI is missing or does not match the registered redirect URI.", ))); } }; if !valid_uri(&uri) || url::Url::parse(&uri).is_ok_and(|u| u.fragment().is_some()) { return Err(Box::new(oauth_error( "invalid_request", "The redirect URI is invalid.", ))); } let a = Authorization { client_id: c.get::("id").to_string(), redirect_uri: uri, requested_redirect_uri: q.get("redirect_uri").cloned(), scopes: q .get("scope") .map(|s| s.split_whitespace().map(str::to_owned).collect()) .unwrap_or_default(), state: q.get("state").cloned(), code_challenge: q.get("code_challenge").cloned(), code_challenge_method: q.get("code_challenge").map(|_| { q.get("code_challenge_method") .cloned() .unwrap_or_else(|| "plain".into()) }), }; if q.get("response_type").map(String::as_str) != Some("code") { return Err(Box::new(redirect_error( &a, "unsupported_response_type", "The authorization server does not support the requested response type.", ))); } if q.get("scope") .is_some_and(|s| s.split_whitespace().any(|v| v != "*")) { return Err(Box::new(redirect_error( &a, "invalid_scope", "The requested scope is invalid, unknown, or malformed.", ))); } if c.get::, _>("secret") .is_none_or(|s| s.is_empty()) && a.code_challenge.is_none() { return Err(Box::new(redirect_error( &a, "invalid_request", "Code challenge must be provided for public clients.", ))); } if a.code_challenge.as_ref().is_some_and(|s| { s.len() < 43 || s.len() > 128 || !s .bytes() .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b)) }) { return Err(Box::new(redirect_error( &a, "invalid_request", "The code challenge is invalid.", ))); } if a.code_challenge_method .as_ref() .is_some_and(|m| m != "plain" && m != "S256") { return Err(Box::new(redirect_error( &a, "invalid_request", "The code challenge method is not supported.", ))); } Ok(a) } fn html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn prompt_cookie(headers: &HeaderMap) -> Option { let mut found = None; for h in headers.get_all(header::COOKIE) { for part in h.to_str().ok()?.split(';') { if let Some(("rust_oauth_prompt", value)) = part.trim().split_once('=') { if found.is_some() || value.len() != 64 || !value.bytes().all(|v| v.is_ascii_alphanumeric()) { return None; } found = Some(value.to_owned()); } } } found } async fn authorize( State(state): State, headers: HeaderMap, Query(q): Query>, ) -> Result { let Some(id) = q.get("client_id") else { return Ok(oauth_error( "invalid_request", "The request is missing a required parameter: client_id.", )); }; let Some(c) = sqlx::query("SELECT * FROM oauth_clients WHERE CAST(id AS TEXT)=? AND revoked=0") .bind(id) .fetch_optional(&state.db) .await? else { return Ok(oauth_error( "invalid_client", "Client authentication failed.", )); }; let a = match validate_authorization(&q, &c) { Ok(a) => a, Err(e) => return Ok(*e), }; let prompt = q .get("prompt") .map(|v| v.split_whitespace().collect::>()) .unwrap_or_default(); let user = crate::auth::browser_user(&state, &headers).await?; // A random, one-use browser cookie survives the deliberate logout and login // session rotation. It proves this exact authorization request has already // prompted for login; the marker expires and cannot approve consent itself. if prompt.contains(&"login") && !prompt.contains(&"none") { let request_hash = crate::auth::digest(&serde_json::to_string(&a).unwrap()); let mut prompted = false; if let Some(marker) = prompt_cookie(&headers) { let row = sqlx::query( "DELETE FROM rust_oauth_login_prompts WHERE id=? RETURNING request_hash,expires_at", ) .bind(crate::auth::digest(&marker)) .fetch_optional(&state.db) .await?; prompted = row.is_some_and(|r| { r.get::("expires_at") > Utc::now().timestamp() && bool::from( r.get::("request_hash") .as_bytes() .ct_eq(request_hash.as_bytes()), ) }); } if !prompted || user.is_none() { crate::auth::invalidate_browser(&state, &headers).await?; let marker = random(64); sqlx::query("DELETE FROM rust_oauth_login_prompts WHERE expires_at<=?") .bind(Utc::now().timestamp()) .execute(&state.db) .await?; sqlx::query( "INSERT INTO rust_oauth_login_prompts(id,request_hash,expires_at) VALUES(?,?,?)", ) .bind(crate::auth::digest(&marker)) .bind(request_hash) .bind(Utc::now().timestamp() + 600) .execute(&state.db) .await?; let mut r = (StatusCode::FOUND, [(header::LOCATION, "/login")]).into_response(); crate::auth::clear_laravel_session_cookies(&mut r); let cookie = format!( "rust_oauth_prompt={marker}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{}", if std::env::var("RUST_COOKIE_SECURE").as_deref() != Ok("false") { "; Secure" } else { "" } ); r.headers_mut().append( header::SET_COOKIE, header::HeaderValue::from_str(&cookie).unwrap(), ); return Ok(r); } } let Some(user) = user else { return Ok(if prompt.contains(&"none") { redirect_error( &a, "login_required", "The authorization server requires end-user authentication.", ) } else { (StatusCode::FOUND, [(header::LOCATION, "/login")]).into_response() }); }; let previously:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM oauth_access_tokens WHERE CAST(client_id AS TEXT)=? AND user_id=? AND revoked=0 AND datetime(expires_at)>datetime('now'))").bind(id).bind(user.id).fetch_one(&state.db).await?; if previously && (!prompt.contains(&"consent") || prompt.contains(&"none")) { return issue_code(&state, user.id, &a).await; } if prompt.contains(&"none") { return Ok(redirect_error( &a, "consent_required", "The authorization server requires user consent.", )); } let sid = crate::auth::browser_session_id(&state, &headers) .await? .ok_or(ApiError(StatusCode::UNAUTHORIZED, "Unauthenticated."))?; sqlx::query("DELETE FROM rust_oauth_authorizations WHERE expires_at<=?") .bind(Utc::now().timestamp()) .execute(&state.db) .await?; let auth_token = random(40); sqlx::query("INSERT INTO rust_oauth_authorizations(session_id,user_id,auth_token,request,expires_at) VALUES(?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET user_id=excluded.user_id,auth_token=excluded.auth_token,request=excluded.request,expires_at=excluded.expires_at").bind(sid).bind(user.id).bind(&auth_token).bind(serde_json::to_string(&a).unwrap()).bind(Utc::now().timestamp()+600).execute(&state.db).await?; let csrf = crate::auth::browser_csrf_token(&state, &headers) .await? .ok_or(ApiError(StatusCode::UNAUTHORIZED, "Unauthenticated."))?; let page = format!( "Authorization Request

Authorization Request

{} is requesting permission to access your account.

", html(&c.get::("name")), html(&csrf), html(&auth_token), html(&csrf), html(&auth_token) ); Ok(( [ (header::CACHE_CONTROL, "no-store"), (header::PRAGMA, "no-cache"), ], Html(page), ) .into_response()) } async fn consume( state: &AppState, user: &AuthUser, headers: &HeaderMap, i: &Value, ) -> Result { let sid = crate::auth::browser_session_id(state, headers) .await? .ok_or(ApiError(StatusCode::UNAUTHORIZED, "Unauthenticated."))?; // Delete even on a mismatched nonce, as Passport's session pull does. A // successful DELETE RETURNING also makes concurrent approval one-use. let r = sqlx::query("DELETE FROM rust_oauth_authorizations WHERE session_id=? RETURNING *") .bind(sid) .fetch_optional(&state.db) .await?; let r = r.ok_or(ApiError( StatusCode::FORBIDDEN, "The provided auth token for the request is different from the session auth token.", ))?; let expected = r.get::("auth_token"); if r.get::("user_id") != user.id || r.get::("expires_at") <= Utc::now().timestamp() || !i["auth_token"] .as_str() .is_some_and(|v| bool::from(v.as_bytes().ct_eq(expected.as_bytes()))) { return Err(ApiError( StatusCode::FORBIDDEN, "The provided auth token for the request is different from the session auth token.", )); } serde_json::from_str(&r.get::("request")) .map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error")) } async fn approve( State(state): State, Extension(user): Extension, headers: HeaderMap, body: Bytes, ) -> Result { let i = input(&headers, &body)?; let a = consume(&state, &user, &headers, &i).await?; if i["_method"] .as_str() .is_some_and(|v| v.eq_ignore_ascii_case("DELETE")) { return Ok(redirect_error( &a, "access_denied", "The resource owner or authorization server denied the request.", )); } issue_code(&state, user.id, &a).await } async fn deny( State(state): State, Extension(user): Extension, headers: HeaderMap, body: Bytes, ) -> Result { let a = consume(&state, &user, &headers, &input(&headers, &body)?).await?; Ok(redirect_error( &a, "access_denied", "The resource owner or authorization server denied the request.", )) } async fn issue_code(state: &AppState, user: i64, a: &Authorization) -> Result { let active: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE CAST(id AS TEXT)=? AND revoked=0)", ) .bind(&a.client_id) .fetch_one(&state.db) .await?; if !active { return Ok(oauth_error( "invalid_client", "Client authentication failed.", )); } let id = random(80); let exp = Utc::now().timestamp() + 600; let code = crate::oauth_server::encrypt_payload( &json!({"client_id":a.client_id,"redirect_uri":a.requested_redirect_uri,"auth_code_id":id,"scopes":a.scopes,"user_id":user.to_string(),"expire_time":exp,"code_challenge":a.code_challenge,"code_challenge_method":a.code_challenge_method}), )?; sqlx::query("INSERT INTO oauth_auth_codes(id,user_id,client_id,scopes,revoked,expires_at) VALUES(?,?,?,?,0,?)").bind(id).bind(user).bind(&a.client_id).bind(json!(a.scopes).to_string()).bind(chrono::DateTime::from_timestamp(exp,0).unwrap().format("%Y-%m-%d %H:%M:%S").to_string()).execute(&state.db).await?; let mut p = vec![("code", code.as_str())]; if let Some(s) = a.state.as_deref() { p.push(("state", s)); } Ok(redirect(&a.redirect_uri, &p)) } #[cfg(test)] mod tests { use super::*; async fn fixture() -> AppState { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); crate::auth::init(&db).await.unwrap(); crate::passport::init(&db).await.unwrap(); crate::oauth_server::init(&db).await.unwrap(); init(&db).await.unwrap(); sqlx::query("INSERT INTO users(id,name,email,password) VALUES(7,'owner','owner@example.test','unused'),(8,'other','other@example.test','unused')").execute(&db).await.unwrap(); AppState { db, cipher: None, encrypt_db: std::sync::atomic::AtomicBool::new(false).into(), } } fn user(id: i64) -> AuthUser { AuthUser { id, is_admin: true } } fn body(v: Value) -> Bytes { Bytes::from(v.to_string()) } async fn response_json(r: Response) -> Value { serde_json::from_slice( &axum::body::to_bytes(r.into_body(), 64 * 1024) .await .unwrap(), ) .unwrap() } async fn create(state: &AppState, confidential: bool) -> Value { response_json(create_client(State(state.clone()),Extension(user(7)),HeaderMap::new(),body(json!({"name":"Example","redirect":"https://client.example/callback","confidential":confidential}))).await.unwrap()).await } #[tokio::test] async fn client_secret_is_hashed_visible_once_and_owned_by_model_type() { let state = fixture().await; let c = create(&state, true).await; let id = c["id"].as_i64().unwrap(); let plain = c["plain_secret"].as_str().unwrap(); assert_eq!(plain.len(), 40); assert!(c.get("secret").is_none()); let hash: String = sqlx::query_scalar("SELECT secret FROM oauth_clients WHERE id=?") .bind(id) .fetch_one(&state.db) .await .unwrap(); assert!(bcrypt::verify(plain, &hash).unwrap()); let Json(list) = list_clients(State(state.clone()), Extension(user(7))) .await .unwrap(); assert_eq!(list.as_array().unwrap().len(), 1); assert!(list[0].get("secret").is_none()); assert!(list[0].get("plain_secret").is_none()); let Json(list) = list_clients(State(state.clone()), Extension(user(8))) .await .unwrap(); assert_eq!(list, json!([])); let other = update_client( State(state.clone()), Extension(user(8)), Path(id.to_string()), HeaderMap::new(), body(json!({"name":"stolen","redirect":"https://attacker.example"})), ) .await .unwrap(); assert_eq!(other.status(), StatusCode::NOT_FOUND); sqlx::query("UPDATE oauth_clients SET owner_type='Other\\User' WHERE id=?") .bind(id) .execute(&state.db) .await .unwrap(); assert_eq!( delete_client( State(state.clone()), Extension(user(7)), Path(id.to_string()) ) .await .unwrap(), StatusCode::NOT_FOUND ); let public = create(&state, false).await; assert!(public.get("plain_secret").is_none()); assert!(public.get("secret").is_none()); let secret: Option = sqlx::query_scalar("SELECT secret FROM oauth_clients WHERE id=?") .bind(public["id"].as_i64().unwrap()) .fetch_one(&state.db) .await .unwrap(); assert!(secret.is_none()); } #[tokio::test] async fn revocations_cover_refresh_tokens_and_enforce_token_owner() { let state = fixture().await; let c = create(&state, false).await; let id = c["id"].as_i64().unwrap(); for (tid, uid) in [("one", 7), ("two", 8)] { sqlx::query("INSERT INTO oauth_access_tokens(id,user_id,client_id,scopes,revoked,expires_at) VALUES(?,?,?,'[]',0,datetime('now','+1 day'))").bind(tid).bind(uid).bind(id).execute(&state.db).await.unwrap(); sqlx::query("INSERT INTO oauth_refresh_tokens(id,access_token_id,revoked,expires_at) VALUES(?,?,0,datetime('now','+1 day'))").bind(format!("refresh-{tid}")).bind(tid).execute(&state.db).await.unwrap(); } assert_eq!( delete_token(State(state.clone()), Extension(user(8)), Path("one".into())) .await .unwrap(), StatusCode::NOT_FOUND ); assert_eq!( delete_token(State(state.clone()), Extension(user(7)), Path("one".into())) .await .unwrap(), StatusCode::NO_CONTENT ); let revoked: bool = sqlx::query_scalar("SELECT revoked FROM oauth_refresh_tokens WHERE id='refresh-one'") .fetch_one(&state.db) .await .unwrap(); assert!(revoked); assert_eq!( delete_client( State(state.clone()), Extension(user(8)), Path(id.to_string()) ) .await .unwrap(), StatusCode::NOT_FOUND ); assert_eq!( delete_client( State(state.clone()), Extension(user(7)), Path(id.to_string()) ) .await .unwrap(), StatusCode::NO_CONTENT ); let count: i64 = sqlx::query_scalar("SELECT count(*) FROM oauth_access_tokens WHERE revoked=0") .fetch_one(&state.db) .await .unwrap(); assert_eq!(count, 0); let count: i64 = sqlx::query_scalar("SELECT count(*) FROM oauth_refresh_tokens WHERE revoked=0") .fetch_one(&state.db) .await .unwrap(); assert_eq!(count, 0); } #[tokio::test] async fn consent_validates_redirect_scope_grant_pkce_before_using_redirect() { let state = fixture().await; let c = create(&state, false).await; let row = sqlx::query("SELECT * FROM oauth_clients WHERE id=?") .bind(c["id"].as_i64().unwrap()) .fetch_one(&state.db) .await .unwrap(); let mut q = HashMap::from([ ("client_id".into(), c["id"].to_string()), ("response_type".into(), "code".into()), ( "redirect_uri".into(), "https://attacker.example/callback".into(), ), ]); let r = validate_authorization(&q, &row).unwrap_err(); assert_eq!(r.status(), StatusCode::BAD_REQUEST); assert!(r.headers().get(header::LOCATION).is_none()); q.insert( "redirect_uri".into(), "https://client.example/callback".into(), ); let r = validate_authorization(&q, &row).unwrap_err(); assert_eq!(r.status(), StatusCode::FOUND); assert!( r.headers()[header::LOCATION] .to_str() .unwrap() .contains("invalid_request") ); q.insert("code_challenge".into(), "a".repeat(43)); q.insert("code_challenge_method".into(), "S256".into()); assert!(validate_authorization(&q, &row).is_ok()); q.insert("scope".into(), "*".into()); assert!(validate_authorization(&q, &row).is_ok()); q.insert("scope".into(), "admin".into()); assert!(validate_authorization(&q, &row).is_err()); q.remove("scope"); q.insert("code_challenge_method".into(), "other".into()); assert!(validate_authorization(&q, &row).is_err()); q.insert("code_challenge_method".into(), "plain".into()); q.insert("code_challenge".into(), "