mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-25 19:54:14 +00:00
295 lines
10 KiB
Rust
295 lines
10 KiB
Rust
use axum::body::Bytes;
|
|
use axum::{Json, extract::State, http::HeaderMap};
|
|
use chrono::{Duration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use tranquil_pds::oauth::{
|
|
AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod,
|
|
OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType,
|
|
scopes::{ParsedScope, parse_scope},
|
|
};
|
|
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
|
|
use tranquil_pds::state::AppState;
|
|
use tranquil_types::RequestId as RequestIdType;
|
|
|
|
const PAR_EXPIRY_SECONDS: i64 = 600;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ParRequest {
|
|
pub response_type: String,
|
|
pub client_id: String,
|
|
pub redirect_uri: String,
|
|
#[serde(default)]
|
|
pub scope: Option<String>,
|
|
#[serde(default)]
|
|
pub state: Option<String>,
|
|
#[serde(default)]
|
|
pub code_challenge: Option<String>,
|
|
#[serde(default)]
|
|
pub code_challenge_method: Option<String>,
|
|
#[serde(default)]
|
|
pub response_mode: Option<String>,
|
|
#[serde(default)]
|
|
pub login_hint: Option<String>,
|
|
#[serde(default)]
|
|
pub dpop_jkt: Option<String>,
|
|
#[serde(default)]
|
|
pub client_secret: Option<String>,
|
|
#[serde(default)]
|
|
pub client_assertion: Option<String>,
|
|
#[serde(default)]
|
|
pub client_assertion_type: Option<String>,
|
|
#[serde(default)]
|
|
pub prompt: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ParResponse {
|
|
pub request_uri: String,
|
|
pub expires_in: u64,
|
|
}
|
|
|
|
pub async fn pushed_authorization_request(
|
|
State(state): State<AppState>,
|
|
_rate_limit: OAuthRateLimited<OAuthParLimit>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
|
|
let content_type = headers
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
let request: ParRequest = if content_type.starts_with("application/json") {
|
|
serde_json::from_slice(&body)
|
|
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
|
|
} else if content_type.starts_with("application/x-www-form-urlencoded") {
|
|
let parsed: ParRequest = serde_urlencoded::from_bytes(&body)
|
|
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?;
|
|
tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)");
|
|
parsed
|
|
} else {
|
|
return Err(OAuthError::InvalidRequest(
|
|
"Content-Type must be application/json or application/x-www-form-urlencoded"
|
|
.to_string(),
|
|
));
|
|
};
|
|
let response_type = parse_response_type(&request.response_type)?;
|
|
let code_challenge = request
|
|
.code_challenge
|
|
.as_ref()
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
|
|
let code_challenge_method =
|
|
parse_code_challenge_method(request.code_challenge_method.as_deref())?;
|
|
let client_cache = ClientMetadataCache::new(3600);
|
|
let client_metadata = client_cache.get(&request.client_id).await?;
|
|
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
|
|
let client_auth = determine_client_auth(&request)?;
|
|
let validated_scope = validate_scope(&request.scope, &client_metadata)?;
|
|
let request_id = RequestId::generate();
|
|
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
|
|
let response_mode = parse_response_mode(request.response_mode.as_deref())?;
|
|
let prompt = parse_prompt(request.prompt.as_deref())?;
|
|
let parameters = AuthorizationRequestParameters {
|
|
response_type,
|
|
client_id: request.client_id.clone(),
|
|
redirect_uri: request.redirect_uri,
|
|
scope: validated_scope,
|
|
state: request.state,
|
|
code_challenge: code_challenge.clone(),
|
|
code_challenge_method,
|
|
response_mode,
|
|
login_hint: request.login_hint,
|
|
dpop_jkt: request.dpop_jkt,
|
|
prompt,
|
|
extra: None,
|
|
};
|
|
let request_data = RequestData {
|
|
client_id: request.client_id,
|
|
client_auth: Some(client_auth),
|
|
parameters,
|
|
expires_at,
|
|
did: None,
|
|
device_id: None,
|
|
code: None,
|
|
controller_did: None,
|
|
};
|
|
let request_id_typed = RequestIdType::from(request_id.0.clone());
|
|
state
|
|
.repos.oauth
|
|
.create_authorization_request(&request_id_typed, &request_data)
|
|
.await
|
|
.map_err(tranquil_pds::oauth::db_err_to_oauth)?;
|
|
tokio::spawn({
|
|
let oauth_repo = state.repos.oauth.clone();
|
|
async move {
|
|
if let Err(e) = oauth_repo.delete_expired_authorization_requests().await {
|
|
tracing::warn!("Failed to cleanup expired authorization requests: {:?}", e);
|
|
}
|
|
}
|
|
});
|
|
Ok((
|
|
axum::http::StatusCode::CREATED,
|
|
Json(ParResponse {
|
|
request_uri: request_id.0,
|
|
expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600),
|
|
}),
|
|
))
|
|
}
|
|
|
|
fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
|
|
if let (Some(assertion), Some(assertion_type)) =
|
|
(&request.client_assertion, &request.client_assertion_type)
|
|
{
|
|
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
|
return Err(OAuthError::InvalidRequest(
|
|
"Unsupported client_assertion_type".to_string(),
|
|
));
|
|
}
|
|
return Ok(ClientAuth::PrivateKeyJwt {
|
|
client_assertion: assertion.clone(),
|
|
});
|
|
}
|
|
if let Some(secret) = &request.client_secret {
|
|
return Ok(ClientAuth::SecretPost {
|
|
client_secret: secret.clone(),
|
|
});
|
|
}
|
|
Ok(ClientAuth::None)
|
|
}
|
|
|
|
fn validate_scope(
|
|
requested_scope: &Option<String>,
|
|
client_metadata: &tranquil_pds::oauth::ClientMetadata,
|
|
) -> Result<Option<String>, OAuthError> {
|
|
let scope_str = match requested_scope {
|
|
Some(s) if !s.is_empty() => s,
|
|
_ => return Ok(Some("atproto".to_string())),
|
|
};
|
|
let requested_scopes: Vec<&str> = scope_str.split_whitespace().collect();
|
|
if requested_scopes.is_empty() {
|
|
return Ok(Some("atproto".to_string()));
|
|
}
|
|
if let Some(unknown) = requested_scopes
|
|
.iter()
|
|
.find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_)))
|
|
{
|
|
return Err(OAuthError::InvalidScope(format!(
|
|
"Unsupported scope: {}",
|
|
unknown
|
|
)));
|
|
}
|
|
|
|
let has_transition = requested_scopes.iter().any(|s| {
|
|
matches!(
|
|
parse_scope(s),
|
|
ParsedScope::TransitionGeneric
|
|
| ParsedScope::TransitionChat
|
|
| ParsedScope::TransitionEmail
|
|
)
|
|
});
|
|
let has_granular = requested_scopes.iter().any(|s| {
|
|
matches!(
|
|
parse_scope(s),
|
|
ParsedScope::Repo(_)
|
|
| ParsedScope::Blob(_)
|
|
| ParsedScope::Rpc(_)
|
|
| ParsedScope::Account(_)
|
|
| ParsedScope::Identity(_)
|
|
| ParsedScope::Include(_)
|
|
)
|
|
});
|
|
|
|
if has_transition && has_granular {
|
|
return Err(OAuthError::InvalidScope(
|
|
"Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
|
|
));
|
|
}
|
|
|
|
if let Some(client_scope) = &client_metadata.scope {
|
|
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
|
|
if let Some(unregistered) = requested_scopes
|
|
.iter()
|
|
.find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope)))
|
|
{
|
|
return Err(OAuthError::InvalidScope(format!(
|
|
"Scope '{}' not registered for this client",
|
|
unregistered
|
|
)));
|
|
}
|
|
}
|
|
Ok(Some(requested_scopes.join(" ")))
|
|
}
|
|
|
|
fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
|
|
if client_scope == requested_scope {
|
|
return true;
|
|
}
|
|
|
|
fn get_resource_type(scope: &str) -> &str {
|
|
let base = scope.split('?').next().unwrap_or(scope);
|
|
base.split(':').next().unwrap_or(base)
|
|
}
|
|
|
|
let client_type = get_resource_type(client_scope);
|
|
let requested_type = get_resource_type(requested_scope);
|
|
|
|
if client_type == requested_type {
|
|
let client_base = client_scope.split('?').next().unwrap_or(client_scope);
|
|
if client_base.contains('*') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> {
|
|
match value {
|
|
"code" => Ok(ResponseType::Code),
|
|
other => Err(OAuthError::InvalidRequest(format!(
|
|
"response_type must be 'code', got '{}'",
|
|
other
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_code_challenge_method(value: Option<&str>) -> Result<CodeChallengeMethod, OAuthError> {
|
|
match value {
|
|
Some("S256") | None => Ok(CodeChallengeMethod::S256),
|
|
Some("plain") => Err(OAuthError::InvalidRequest(
|
|
"code_challenge_method 'plain' is not allowed, use 'S256'".to_string(),
|
|
)),
|
|
Some(other) => Err(OAuthError::InvalidRequest(format!(
|
|
"Unsupported code_challenge_method: {}",
|
|
other
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_response_mode(value: Option<&str>) -> Result<Option<ResponseMode>, OAuthError> {
|
|
match value {
|
|
None | Some("query") => Ok(None),
|
|
Some("fragment") => Ok(Some(ResponseMode::Fragment)),
|
|
Some("form_post") => Ok(Some(ResponseMode::FormPost)),
|
|
Some(other) => Err(OAuthError::InvalidRequest(format!(
|
|
"Unsupported response_mode: {}",
|
|
other
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn parse_prompt(value: Option<&str>) -> Result<Option<Prompt>, OAuthError> {
|
|
match value {
|
|
None | Some("") => Ok(None),
|
|
Some("none") => Ok(Some(Prompt::None)),
|
|
Some("login") => Ok(Some(Prompt::Login)),
|
|
Some("consent") => Ok(Some(Prompt::Consent)),
|
|
Some("select_account") => Ok(Some(Prompt::SelectAccount)),
|
|
Some("create") => Ok(Some(Prompt::Create)),
|
|
Some(other) => Err(OAuthError::InvalidRequest(format!(
|
|
"Unsupported prompt value: {}",
|
|
other
|
|
))),
|
|
}
|
|
}
|