From 2066c7f76ea2af14670c547e42d13364b3275b03 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 12 Jun 2026 20:56:29 -0500 Subject: [PATCH] fix: handle the scope format returned from Bluesky PDS Signed-off-by: Trezy --- packages/oauth-client/src/client.ts | 8 ++--- src/oauth/client_auth.rs | 55 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index 97bd304..f9bf900 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -99,7 +99,7 @@ export class HappyViewOAuthClient { if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to provision DPoP key: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to provision DPoP key: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -149,7 +149,7 @@ export class HappyViewOAuthClient { if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to register session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to register session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -203,7 +203,7 @@ export class HappyViewOAuthClient { if (!resp.ok && resp.status !== 404) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to delete session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to delete session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); @@ -254,7 +254,7 @@ export class HappyViewOAuthClient { if (!resp.ok) { const body = await resp.json().catch(() => ({})); throw new ApiError( - `Failed to get session: ${resp.status} ${(body as any).message ?? resp.statusText}`, + `Failed to get session: ${resp.status} ${(body as any).error ?? (body as any).message ?? resp.statusText}`, resp.status, body, ); diff --git a/src/oauth/client_auth.rs b/src/oauth/client_auth.rs index 2f6e651..d340ce0 100644 --- a/src/oauth/client_auth.rs +++ b/src/oauth/client_auth.rs @@ -212,6 +212,16 @@ pub async fn validate_scopes( } } + // The PDS also grants bare `repo:COLLECTION` scopes (without + // `?action=`). Match if the expanded client set has any + // `repo:COLLECTION?action=...` entry for that collection. + if let Some(collection) = scope.strip_prefix("repo:") { + let prefix = format!("repo:{}?", collection); + if client_set.iter().any(|cs| cs.starts_with(&prefix)) { + continue; + } + } + return Err(AppError::BadRequest(format!( "scope '{}' is not allowed for this client", scope @@ -496,6 +506,51 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn validate_scopes_bare_repo_collection_allowed_with_expanded_permissions() { + let reg = empty_registry(); + let raw = serde_json::json!({ + "lexicon": 1, + "id": "com.example.authBasic", + "defs": { + "main": { + "type": "permission-set", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": ["com.example.profile", "com.example.post"] + } + ] + } + } + }); + let parsed = crate::lexicon::ParsedLexicon::parse( + raw, + 1, + None, + crate::lexicon::ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let result = validate_scopes( + "atproto repo:com.example.profile", + "atproto include:com.example.authBasic", + ®, + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn validate_scopes_bare_repo_collection_rejected_without_permission() { + let reg = empty_registry(); + let result = validate_scopes("atproto repo:com.example.secret", "atproto", ®).await; + assert!(result.is_err()); + } + #[test] fn verify_pkce_valid() { use base64::Engine; -- 2.51.2