Files
twofauth-rs/src/oauth_management.rs
wt cf25e28c28
Rust verification / test (push) Successful in 4m10s
initial commit
2026-10-28 15:59:54 +07:00

1239 lines
49 KiB
Rust

//! 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<AppState> {
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<AppState> {
// 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::<Option<String>, _>(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::<i64,_>("id"),"owner_id":row.get::<Option<i64>,_>("owner_id"),"owner_type":row.get::<Option<String>,_>("owner_type"),"name":row.get::<String,_>("name"),"provider":row.get::<Option<String>,_>("provider"),"redirect_uris":serde_json::from_str::<Value>(&row.get::<String,_>("redirect_uris")).unwrap_or(json!([])),"grant_types":serde_json::from_str::<Value>(&row.get::<String,_>("grant_types")).unwrap_or(json!([])),"revoked":row.get::<bool,_>("revoked"),"created_at":date(row,"created_at"),"updated_at":date(row,"updated_at")})
}
fn token_json(row: &SqliteRow) -> Value {
json!({"id":row.get::<String,_>("id"),"user_id":row.get::<Option<i64>,_>("user_id"),"client_id":row.get::<i64,_>("client_id"),"name":row.get::<Option<String>,_>("name"),"scopes":serde_json::from_str::<Value>(&row.get::<Option<String>,_>("scopes").unwrap_or_else(||"[]".into())).unwrap_or(json!([])),"revoked":row.get::<bool,_>("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<Value, ApiError> {
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<String, String> = 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<bool> {
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<Option<SqliteRow>, 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<AppState>,
Extension(user): Extension<AuthUser>,
) -> Result<Json<Value>, 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::<Vec<_>>()
)))
}
async fn create_client(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
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::<Vec<_>>();
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<AppState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
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::<Vec<_>>()).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<AppState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Extension(user): Extension<AuthUser>,
) -> Result<Json<Value>, 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::<i64, _>("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<AppState>,
Extension(user): Extension<AuthUser>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
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<String>,
#[serde(default)]
scopes: Vec<String>,
state: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
}
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::<Vec<_>>();
if let Some(s) = a.state.as_deref() {
p.push(("state", s));
}
redirect(&a.redirect_uri, &p)
}
fn validate_authorization(
q: &HashMap<String, String>,
c: &SqliteRow,
) -> Result<Authorization, Box<Response>> {
let grants: Vec<String> =
serde_json::from_str(&c.get::<String, _>("grant_types")).unwrap_or_default();
if !grants.iter().any(|g| g == "authorization_code")
|| c.get::<Option<String>, _>("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<String> =
serde_json::from_str(&c.get::<String, _>("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::<i64, _>("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::<Option<String>, _>("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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn prompt_cookie(headers: &HeaderMap) -> Option<String> {
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<AppState>,
headers: HeaderMap,
Query(q): Query<HashMap<String, String>>,
) -> Result<Response, ApiError> {
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::<Vec<_>>())
.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::<i64, _>("expires_at") > Utc::now().timestamp()
&& bool::from(
r.get::<String, _>("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!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Authorization Request</title></head><body><h1>Authorization Request</h1><p><strong>{}</strong> is requesting permission to access your account.</p><form method=\"post\"><input type=\"hidden\" name=\"_token\" value=\"{}\"><input type=\"hidden\" name=\"auth_token\" value=\"{}\"><button type=\"submit\">Authorize</button></form><form method=\"post\"><input type=\"hidden\" name=\"_token\" value=\"{}\"><input type=\"hidden\" name=\"auth_token\" value=\"{}\"><input type=\"hidden\" name=\"_method\" value=\"DELETE\"><button type=\"submit\">Deny</button></form></body></html>",
html(&c.get::<String, _>("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<Authorization, ApiError> {
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::<String, _>("auth_token");
if r.get::<i64, _>("user_id") != user.id
|| r.get::<i64, _>("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::<String, _>("request"))
.map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))
}
async fn approve(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
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<AppState>,
Extension(user): Extension<AuthUser>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ApiError> {
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<Response, ApiError> {
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<String> =
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(), "<script>".into());
assert!(validate_authorization(&q, &row).is_err());
assert_eq!(html("<\"&'"), "&lt;&quot;&amp;&#39;");
}
#[tokio::test]
async fn client_validation_rejects_bad_types_invalid_urls_and_long_names() {
let state = fixture().await;
for value in [
json!({"name":"x","redirect":"javascript:alert(1)"}),
json!({"name":"x","redirect":"https://example.test","confidential":"false"}),
json!({"name":"a".repeat(256),"redirect":"https://example.test"}),
json!({"redirect":"https://example.test"}),
] {
let r = create_client(
State(state.clone()),
Extension(user(7)),
HeaderMap::new(),
body(value),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(response_json(r).await["errors"].is_object());
}
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM oauth_clients WHERE owner_id=7")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn authorization_nonce_is_browser_bound_consumed_once_and_fails_closed() {
let state = fixture().await;
let sid = "a".repeat(64);
let csrf = "b".repeat(64);
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,7,?,?)",
)
.bind(crate::auth::digest(&sid))
.bind(crate::auth::digest(&csrf))
.bind(Utc::now().timestamp() + 3600)
.execute(&state.db)
.await
.unwrap();
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
format!("rust_2fauth_session={sid}; XSRF-TOKEN={csrf}")
.parse()
.unwrap(),
);
let browser = crate::auth::browser_session_id(&state, &headers)
.await
.unwrap()
.unwrap();
let a = Authorization {
client_id: "1".into(),
redirect_uri: "https://client.example/callback".into(),
requested_redirect_uri: Some("https://client.example/callback".into()),
scopes: vec![],
state: Some("untrusted<&".into()),
code_challenge: None,
code_challenge_method: None,
};
async fn store(state: &AppState, browser: &str, a: &Authorization) {
sqlx::query("INSERT INTO rust_oauth_authorizations(session_id,user_id,auth_token,request,expires_at) VALUES(?,7,'nonce',?,?)").bind(browser).bind(serde_json::to_string(a).unwrap()).bind(Utc::now().timestamp()+600).execute(&state.db).await.unwrap();
}
store(&state, &browser, &a).await;
assert!(
consume(
&state,
&user(7),
&HeaderMap::new(),
&json!({"auth_token":"nonce"})
)
.await
.is_err()
);
assert!(
consume(&state, &user(7), &headers, &json!({"auth_token":"wrong"}))
.await
.is_err()
);
assert!(
consume(&state, &user(7), &headers, &json!({"auth_token":"nonce"}))
.await
.is_err()
);
store(&state, &browser, &a).await;
assert!(
consume(&state, &user(8), &headers, &json!({"auth_token":"nonce"}))
.await
.is_err()
);
store(&state, &browser, &a).await;
let request=consume(&state,&user(7),&headers,&json!({"auth_token":"nonce","client_id":"attacker","redirect_uri":"https://attacker.example","user_id":8})).await.unwrap();
assert_eq!(request.client_id, "1");
assert_eq!(request.redirect_uri, "https://client.example/callback");
assert!(
consume(&state, &user(7), &headers, &json!({"auth_token":"nonce"}))
.await
.is_err()
);
store(&state, &browser, &a).await;
sqlx::query("UPDATE rust_oauth_authorizations SET expires_at=0")
.execute(&state.db)
.await
.unwrap();
assert!(
consume(&state, &user(7), &headers, &json!({"auth_token":"nonce"}))
.await
.is_err()
);
let r = redirect(&a.redirect_uri, &[("state", a.state.as_deref().unwrap())]);
let u = url::Url::parse(r.headers()[header::LOCATION].to_str().unwrap()).unwrap();
assert_eq!(
u.query_pairs().find(|(k, _)| k == "state").unwrap().1,
"untrusted<&"
);
}
#[tokio::test]
async fn router_keeps_source_defaults_and_requires_browser_csrf_for_opt_in() {
use tower::ServiceExt;
let state = fixture().await;
let app = routes_with_options(state.clone(), false, false).with_state(state.clone());
let r = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/oauth/authorize")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
let r = app
.oneshot(
axum::http::Request::builder()
.uri("/oauth/clients")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
let app = routes_with_options(state.clone(), true, true).with_state(state.clone());
let r = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/oauth/clients")
.header(header::AUTHORIZATION, "Bearer ignored")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
let sid = "c".repeat(64);
let csrf = "d".repeat(64);
sqlx::query(
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,7,?,?)",
)
.bind(crate::auth::digest(&sid))
.bind(crate::auth::digest(&csrf))
.bind(Utc::now().timestamp() + 3600)
.execute(&state.db)
.await
.unwrap();
let cookie = format!("rust_2fauth_session={sid}; XSRF-TOKEN={csrf}");
let r = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/oauth/clients")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
json!({"name":"test","redirect":"https://example.test"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status().as_u16(), 419);
let r=app.oneshot(axum::http::Request::builder().method("POST").uri("/oauth/clients").header(header::COOKIE,&cookie).header("x-csrf-token",&csrf).header(header::CONTENT_TYPE,"application/json").body(axum::body::Body::from(json!({"name":"test","redirect":"https://example.test","confidential":false}).to_string())).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
#[tokio::test]
async fn prompt_login_invalidates_old_browser_and_allows_one_resumed_request() {
let state = fixture().await;
let c = create(&state, false).await;
let csrf = "e".repeat(64);
async fn browser(
state: &AppState,
sid: &str,
csrf: &str,
marker: Option<&str>,
) -> HeaderMap {
sqlx::query("INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,7,?,?)").bind(crate::auth::digest(sid)).bind(crate::auth::digest(csrf)).bind(Utc::now().timestamp()+3600).execute(&state.db).await.unwrap();
let mut h = HeaderMap::new();
let mut cookie = format!("rust_2fauth_session={sid}; XSRF-TOKEN={csrf}");
if let Some(marker) = marker {
cookie.push_str(&format!("; {marker}"));
}
h.insert(header::COOKIE, cookie.parse().unwrap());
h
}
let sid = "f".repeat(64);
let headers = browser(&state, &sid, &csrf, None).await;
let q = HashMap::from([
("client_id".into(), c["id"].to_string()),
("response_type".into(), "code".into()),
(
"redirect_uri".into(),
"https://client.example/callback".into(),
),
("code_challenge".into(), "a".repeat(43)),
("prompt".into(), "login".into()),
]);
let r = authorize(State(state.clone()), headers, Query(q.clone()))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::FOUND);
assert_eq!(r.headers()[header::LOCATION], "/login");
let active: i64 =
sqlx::query_scalar("SELECT count(*) FROM rust_sessions WHERE token_hash=?")
.bind(crate::auth::digest(&sid))
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(active, 0);
let marker = r
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.find(|v| v.starts_with("rust_oauth_prompt="))
.unwrap()
.split(';')
.next()
.unwrap()
.to_string();
let resumed = browser(&state, &"1".repeat(64), &csrf, Some(&marker)).await;
let r = authorize(State(state.clone()), resumed.clone(), Query(q.clone()))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let page = String::from_utf8(
axum::body::to_bytes(r.into_body(), 64 * 1024)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(page.contains("Authorization Request"));
assert!(page.contains("name=\"_token\""));
let r = authorize(State(state.clone()), resumed, Query(q))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::FOUND);
assert_eq!(r.headers()[header::LOCATION], "/login");
}
}