forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
10 kB
302 lines
1use axum::body::Bytes;
2use axum::{Json, extract::State, http::HeaderMap};
3use chrono::{Duration, Utc};
4use serde::{Deserialize, Serialize};
5use tranquil_pds::oauth::{
6 AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod,
7 OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType,
8 scopes::{ParsedScope, parse_scope},
9};
10use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
11use tranquil_pds::state::AppState;
12use tranquil_types::{ClientId, JwkThumbprint};
13
14const PAR_EXPIRY_SECONDS: i64 = 600;
15
16#[derive(Debug, Deserialize)]
17pub struct ParRequest {
18 pub response_type: String,
19 pub client_id: ClientId,
20 pub redirect_uri: String,
21 #[serde(default)]
22 pub scope: Option<String>,
23 #[serde(default)]
24 pub state: Option<String>,
25 #[serde(default)]
26 pub code_challenge: Option<String>,
27 #[serde(default)]
28 pub code_challenge_method: Option<String>,
29 #[serde(default)]
30 pub response_mode: Option<String>,
31 #[serde(default)]
32 pub login_hint: Option<String>,
33 #[serde(default)]
34 pub dpop_jkt: Option<JwkThumbprint>,
35 #[serde(default)]
36 pub client_secret: Option<String>,
37 #[serde(default)]
38 pub client_assertion: Option<String>,
39 #[serde(default)]
40 pub client_assertion_type: Option<String>,
41 #[serde(default)]
42 pub prompt: Option<String>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct ParResponse {
47 pub request_uri: String,
48 pub expires_in: u64,
49}
50
51pub async fn pushed_authorization_request(
52 State(state): State<AppState>,
53 _rate_limit: OAuthRateLimited<OAuthParLimit>,
54 headers: HeaderMap,
55 body: Bytes,
56) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
57 let content_type = headers
58 .get("content-type")
59 .and_then(|v| v.to_str().ok())
60 .unwrap_or("");
61 let request: ParRequest = if content_type.starts_with("application/json") {
62 serde_json::from_slice(&body)
63 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
64 } else if content_type.starts_with("application/x-www-form-urlencoded") {
65 let parsed: ParRequest = serde_urlencoded::from_bytes(&body)
66 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?;
67 tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)");
68 parsed
69 } else {
70 return Err(OAuthError::InvalidRequest(
71 "Content-Type must be application/json or application/x-www-form-urlencoded"
72 .to_string(),
73 ));
74 };
75 let response_type = parse_response_type(&request.response_type)?;
76 let code_challenge = request
77 .code_challenge
78 .as_ref()
79 .filter(|s| !s.is_empty())
80 .ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
81 let code_challenge_method =
82 parse_code_challenge_method(request.code_challenge_method.as_deref())?;
83 let client_cache = ClientMetadataCache::new(3600);
84 let client_metadata = client_cache.get(&request.client_id).await?;
85 client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
86 let client_auth = determine_client_auth(&request)?;
87 let validated_scope = validate_scope(&request.scope, &client_metadata)?;
88 let request_id = RequestId::generate();
89 let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
90 let response_mode = parse_response_mode(request.response_mode.as_deref())?;
91 let prompt = parse_prompt(request.prompt.as_deref())?;
92 let parameters = AuthorizationRequestParameters {
93 response_type,
94 client_id: request.client_id.clone(),
95 redirect_uri: request.redirect_uri,
96 scope: validated_scope,
97 state: request.state,
98 code_challenge: code_challenge.clone(),
99 code_challenge_method,
100 response_mode,
101 login_hint: request.login_hint,
102 dpop_jkt: request.dpop_jkt,
103 prompt,
104 extra: None,
105 };
106 let request_data = RequestData {
107 client_id: request.client_id,
108 client_auth: Some(client_auth),
109 parameters,
110 expires_at,
111 did: None,
112 device_id: None,
113 code: None,
114 controller_did: None,
115 };
116 state
117 .repos
118 .oauth
119 .create_authorization_request(&request_id, &request_data)
120 .await
121 .map_err(tranquil_pds::oauth::db_err_to_oauth)?;
122 tokio::spawn({
123 let oauth_repo = state.repos.oauth.clone();
124 async move {
125 if let Err(e) = oauth_repo.delete_expired_authorization_requests().await {
126 tracing::warn!("Failed to cleanup expired authorization requests: {:?}", e);
127 }
128 }
129 });
130 Ok((
131 axum::http::StatusCode::CREATED,
132 Json(ParResponse {
133 request_uri: request_id.into_inner(),
134 expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600),
135 }),
136 ))
137}
138
139fn determine_client_auth(request: &ParRequest) -> Result<ClientAuth, OAuthError> {
140 let assertion = request
141 .client_assertion
142 .as_deref()
143 .filter(|s| !s.is_empty());
144 let assertion_type = request
145 .client_assertion_type
146 .as_deref()
147 .filter(|s| !s.is_empty());
148 let secret = request.client_secret.as_deref().filter(|s| !s.is_empty());
149
150 if let (Some(assertion), Some(assertion_type)) = (assertion, assertion_type) {
151 if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
152 return Err(OAuthError::InvalidRequest(
153 "Unsupported client_assertion_type".to_string(),
154 ));
155 }
156 return Ok(ClientAuth::PrivateKeyJwt {
157 client_assertion: assertion.to_string(),
158 });
159 }
160 if let Some(secret) = secret {
161 return Ok(ClientAuth::SecretPost {
162 client_secret: secret.to_string(),
163 });
164 }
165 Ok(ClientAuth::None)
166}
167
168fn validate_scope(
169 requested_scope: &Option<String>,
170 client_metadata: &tranquil_pds::oauth::ClientMetadata,
171) -> Result<Option<String>, OAuthError> {
172 let scope_str = match requested_scope {
173 Some(s) if !s.is_empty() => s,
174 _ => return Ok(Some("atproto".to_string())),
175 };
176 let requested_scopes: Vec<&str> = scope_str.split_whitespace().collect();
177 if requested_scopes.is_empty() {
178 return Ok(Some("atproto".to_string()));
179 }
180 if let Some(unknown) = requested_scopes
181 .iter()
182 .find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_)))
183 {
184 return Err(OAuthError::InvalidScope(format!(
185 "Unsupported scope: {}",
186 unknown
187 )));
188 }
189
190 let has_transition = requested_scopes.iter().any(|s| {
191 matches!(
192 parse_scope(s),
193 ParsedScope::TransitionGeneric
194 | ParsedScope::TransitionChat
195 | ParsedScope::TransitionEmail
196 )
197 });
198 let has_granular = requested_scopes.iter().any(|s| {
199 matches!(
200 parse_scope(s),
201 ParsedScope::Repo(_)
202 | ParsedScope::Blob(_)
203 | ParsedScope::Rpc(_)
204 | ParsedScope::Account(_)
205 | ParsedScope::Identity(_)
206 | ParsedScope::Include(_)
207 )
208 });
209
210 if has_transition && has_granular {
211 return Err(OAuthError::InvalidScope(
212 "Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
213 ));
214 }
215
216 if let Some(client_scope) = &client_metadata.scope {
217 let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
218 if let Some(unregistered) = requested_scopes
219 .iter()
220 .find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope)))
221 {
222 return Err(OAuthError::InvalidScope(format!(
223 "Scope '{}' not registered for this client",
224 unregistered
225 )));
226 }
227 }
228 Ok(Some(requested_scopes.join(" ")))
229}
230
231fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
232 if client_scope == requested_scope {
233 return true;
234 }
235
236 fn get_resource_type(scope: &str) -> &str {
237 let base = scope.split('?').next().unwrap_or(scope);
238 base.split(':').next().unwrap_or(base)
239 }
240
241 let client_type = get_resource_type(client_scope);
242 let requested_type = get_resource_type(requested_scope);
243
244 if client_type == requested_type {
245 let client_base = client_scope.split('?').next().unwrap_or(client_scope);
246 if client_base.contains('*') {
247 return true;
248 }
249 }
250
251 false
252}
253
254fn parse_response_type(value: &str) -> Result<ResponseType, OAuthError> {
255 match value {
256 "code" => Ok(ResponseType::Code),
257 other => Err(OAuthError::InvalidRequest(format!(
258 "response_type must be 'code', got '{}'",
259 other
260 ))),
261 }
262}
263
264fn parse_code_challenge_method(value: Option<&str>) -> Result<CodeChallengeMethod, OAuthError> {
265 match value {
266 Some("S256") | None => Ok(CodeChallengeMethod::S256),
267 Some("plain") => Err(OAuthError::InvalidRequest(
268 "code_challenge_method 'plain' is not allowed, use 'S256'".to_string(),
269 )),
270 Some(other) => Err(OAuthError::InvalidRequest(format!(
271 "Unsupported code_challenge_method: {}",
272 other
273 ))),
274 }
275}
276
277fn parse_response_mode(value: Option<&str>) -> Result<Option<ResponseMode>, OAuthError> {
278 match value {
279 None | Some("query") => Ok(None),
280 Some("fragment") => Ok(Some(ResponseMode::Fragment)),
281 Some("form_post") => Ok(Some(ResponseMode::FormPost)),
282 Some(other) => Err(OAuthError::InvalidRequest(format!(
283 "Unsupported response_mode: {}",
284 other
285 ))),
286 }
287}
288
289fn parse_prompt(value: Option<&str>) -> Result<Option<Prompt>, OAuthError> {
290 match value {
291 None | Some("") => Ok(None),
292 Some("none") => Ok(Some(Prompt::None)),
293 Some("login") => Ok(Some(Prompt::Login)),
294 Some("consent") => Ok(Some(Prompt::Consent)),
295 Some("select_account") => Ok(Some(Prompt::SelectAccount)),
296 Some("create") => Ok(Some(Prompt::Create)),
297 Some(other) => Err(OAuthError::InvalidRequest(format!(
298 "Unsupported prompt value: {}",
299 other
300 ))),
301 }
302}