1239 lines
49 KiB
Rust
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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
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("<\"&'"), "<"&'");
|
|
}
|
|
#[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");
|
|
}
|
|
}
|