A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(auth): better handling for allowed scopes during oauth

Trezy b27b8835 7a9bc75b

+136 -5
+136 -5
src/oauth/client_auth.rs
··· 158 158 /// - Every non-`atproto` scope in the token must appear in the client's registered scopes 159 159 /// - `include:X` client scopes are expanded by looking up the permission set 160 160 /// lexicon `X` and extracting its `rpc:` and `repo:` permissions 161 + /// - `repo?collection=X&collection=Y` scopes (PDS-granted) are allowed if the 162 + /// client has `transition:generic` or has collection-level permissions from 163 + /// expanded `include:` scopes 161 164 pub async fn validate_scopes( 162 165 token_scopes: &str, 163 166 client_scopes: &str, ··· 179 182 )); 180 183 } 181 184 185 + let has_generic = client_set.contains("transition:generic"); 186 + 182 187 for scope in &token_set { 183 188 if *scope == "atproto" { 184 189 continue; 185 190 } 186 - if !client_set.contains(*scope) { 187 - return Err(AppError::BadRequest(format!( 188 - "scope '{}' is not allowed for this client", 189 - scope 190 - ))); 191 + if client_set.contains(*scope) { 192 + continue; 193 + } 194 + 195 + // The PDS grants `repo?collection=X&collection=Y` to restrict which 196 + // collections the token can access. Allow if the client has broad 197 + // access (`transition:generic`) or has matching collection-level 198 + // permissions from expanded `include:` scopes. 199 + if let Some(query) = scope.strip_prefix("repo?") { 200 + if has_generic { 201 + continue; 202 + } 203 + let all_allowed = query.split('&').all(|param| { 204 + let Some(col) = param.strip_prefix("collection=") else { 205 + return true; 206 + }; 207 + let prefix = format!("repo:{}?", col); 208 + client_set.iter().any(|cs| cs.starts_with(&prefix)) 209 + }); 210 + if all_allowed { 211 + continue; 212 + } 191 213 } 214 + 215 + return Err(AppError::BadRequest(format!( 216 + "scope '{}' is not allowed for this client", 217 + scope 218 + ))); 192 219 } 193 220 194 221 Ok(()) ··· 364 391 365 392 let result = validate_scopes( 366 393 "atproto rpc:com.example.notAllowed", 394 + "atproto include:com.example.authBasic", 395 + &reg, 396 + ) 397 + .await; 398 + assert!(result.is_err()); 399 + } 400 + 401 + #[tokio::test] 402 + async fn validate_scopes_repo_collection_allowed_with_transition_generic() { 403 + let reg = empty_registry(); 404 + let result = validate_scopes( 405 + "atproto transition:generic repo?collection=com.example.profile&collection=com.example.post", 406 + "atproto transition:generic", 407 + &reg, 408 + ) 409 + .await; 410 + assert!(result.is_ok()); 411 + } 412 + 413 + #[tokio::test] 414 + async fn validate_scopes_repo_collection_allowed_with_expanded_permissions() { 415 + let reg = empty_registry(); 416 + let raw = serde_json::json!({ 417 + "lexicon": 1, 418 + "id": "com.example.authBasic", 419 + "defs": { 420 + "main": { 421 + "type": "permission-set", 422 + "permissions": [ 423 + { 424 + "type": "permission", 425 + "resource": "repo", 426 + "collection": ["com.example.profile", "com.example.post"] 427 + } 428 + ] 429 + } 430 + } 431 + }); 432 + let parsed = crate::lexicon::ParsedLexicon::parse( 433 + raw, 434 + 1, 435 + None, 436 + crate::lexicon::ProcedureAction::Upsert, 437 + None, 438 + None, 439 + None, 440 + ) 441 + .unwrap(); 442 + reg.upsert(parsed).await; 443 + 444 + let result = validate_scopes( 445 + "atproto repo?collection=com.example.profile&collection=com.example.post", 446 + "atproto include:com.example.authBasic", 447 + &reg, 448 + ) 449 + .await; 450 + assert!(result.is_ok()); 451 + } 452 + 453 + #[tokio::test] 454 + async fn validate_scopes_repo_collection_rejected_without_permission() { 455 + let reg = empty_registry(); 456 + let result = validate_scopes( 457 + "atproto repo?collection=com.example.profile", 458 + "atproto", 459 + &reg, 460 + ) 461 + .await; 462 + assert!(result.is_err()); 463 + } 464 + 465 + #[tokio::test] 466 + async fn validate_scopes_repo_collection_rejected_partial_match() { 467 + let reg = empty_registry(); 468 + let raw = serde_json::json!({ 469 + "lexicon": 1, 470 + "id": "com.example.authBasic", 471 + "defs": { 472 + "main": { 473 + "type": "permission-set", 474 + "permissions": [ 475 + { 476 + "type": "permission", 477 + "resource": "repo", 478 + "collection": ["com.example.profile"] 479 + } 480 + ] 481 + } 482 + } 483 + }); 484 + let parsed = crate::lexicon::ParsedLexicon::parse( 485 + raw, 486 + 1, 487 + None, 488 + crate::lexicon::ProcedureAction::Upsert, 489 + None, 490 + None, 491 + None, 492 + ) 493 + .unwrap(); 494 + reg.upsert(parsed).await; 495 + 496 + let result = validate_scopes( 497 + "atproto repo?collection=com.example.profile&collection=com.example.secret", 367 498 "atproto include:com.example.authBasic", 368 499 &reg, 369 500 )