Files
tranquil-pds/crates/tranquil-oauth-server/src/endpoints/par.rs
T

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
))),
}
}