Laravel AT Protocol Client (alpha & unstable)
at main 8.2 kB view raw
1<?php 2 3namespace SocialDept\AtpClient\Auth; 4 5use BackedEnum; 6use Illuminate\Support\Facades\Log; 7use SocialDept\AtpClient\Enums\Scope; 8use SocialDept\AtpClient\Enums\ScopeEnforcementLevel; 9use SocialDept\AtpClient\Exceptions\MissingScopeException; 10use SocialDept\AtpClient\Session\Session; 11 12class ScopeChecker 13{ 14 public function __construct( 15 protected ScopeEnforcementLevel $enforcement = ScopeEnforcementLevel::Permissive 16 ) {} 17 18 /** 19 * Check if the session has all required scopes. 20 * 21 * @param array<string|Scope> $requiredScopes 22 */ 23 public function check(Session $session, array $requiredScopes): bool 24 { 25 $required = $this->normalizeScopes($requiredScopes); 26 $granted = $session->scopes(); 27 28 foreach ($required as $scope) { 29 if (! $this->sessionHasScope($session, $scope)) { 30 return false; 31 } 32 } 33 34 return true; 35 } 36 37 /** 38 * Check scopes and handle enforcement based on configuration. 39 * 40 * @param array<string|Scope> $requiredScopes 41 * 42 * @throws MissingScopeException 43 */ 44 public function checkOrFail(Session $session, array $requiredScopes): void 45 { 46 if ($this->check($session, $requiredScopes)) { 47 return; 48 } 49 50 $required = $this->normalizeScopes($requiredScopes); 51 $granted = $session->scopes(); 52 $missing = array_diff($required, $granted); 53 54 if ($this->enforcement === ScopeEnforcementLevel::Strict) { 55 throw new MissingScopeException($missing, $granted); 56 } 57 58 Log::warning('ATP Client: Missing required scope(s)', [ 59 'required' => $required, 60 'granted' => $granted, 61 'missing' => $missing, 62 'did' => $session->did(), 63 ]); 64 } 65 66 /** 67 * Check if the session has a specific scope. 68 */ 69 public function hasScope(Session $session, string|Scope $scope): bool 70 { 71 $scope = $scope instanceof Scope ? $scope->value : $scope; 72 73 return $this->sessionHasScope($session, $scope); 74 } 75 76 /** 77 * Check if the session matches a granular scope pattern. 78 * 79 * Supports patterns like: 80 * - repo:app.bsky.feed.post?action=create 81 * - repo:app.bsky.feed.* 82 * - rpc:app.bsky.feed.* 83 * - blob:image/* 84 */ 85 public function matchesGranular(Session $session, string $pattern): bool 86 { 87 $granted = $session->scopes(); 88 89 // Check for exact match first 90 if (in_array($pattern, $granted, true)) { 91 return true; 92 } 93 94 // Handle repo: scopes with action semantics 95 if (str_starts_with($pattern, 'repo:')) { 96 foreach ($granted as $scope) { 97 if (str_starts_with($scope, 'repo:') && $this->matchesRepoScope($pattern, $scope)) { 98 return true; 99 } 100 } 101 } 102 103 // Check for wildcard matches 104 $patternRegex = $this->patternToRegex($pattern); 105 106 foreach ($granted as $scope) { 107 if (preg_match($patternRegex, $scope)) { 108 return true; 109 } 110 } 111 112 // Check if granted scope is a superset (wildcard in granted scope) 113 foreach ($granted as $scope) { 114 $grantedRegex = $this->patternToRegex($scope); 115 if (preg_match($grantedRegex, $pattern)) { 116 return true; 117 } 118 } 119 120 return false; 121 } 122 123 /** 124 * Check if a required repo scope is satisfied by a granted repo scope. 125 * 126 * Per AT Protocol spec: "If not defined, all operations are allowed." 127 * - repo:collection (no action) grants ALL actions 128 * - repo:collection?action=create grants only create 129 * - repo:* grants all collections with all actions 130 */ 131 protected function matchesRepoScope(string $required, string $granted): bool 132 { 133 $requiredParsed = $this->parseRepoScope($required); 134 $grantedParsed = $this->parseRepoScope($granted); 135 136 // Check collection match (with wildcard support) 137 if (! $this->collectionsMatch($requiredParsed['collection'], $grantedParsed['collection'])) { 138 return false; 139 } 140 141 // If granted has no actions, it grants ALL actions 142 if (empty($grantedParsed['actions'])) { 143 return true; 144 } 145 146 // If required has no actions, we need all actions granted 147 if (empty($requiredParsed['actions'])) { 148 // Required needs all actions, but granted is restricted 149 return false; 150 } 151 152 // Check if all required actions are in granted actions 153 return empty(array_diff($requiredParsed['actions'], $grantedParsed['actions'])); 154 } 155 156 /** 157 * Parse a repo scope into collection and actions. 158 * 159 * Handles formats like: 160 * - repo:app.bsky.feed.post 161 * - repo:app.bsky.feed.post?action=create 162 * - repo:app.bsky.feed.post?action=create&action=update&action=delete 163 * - repo:* 164 * - repo:*?action=delete 165 * 166 * @return array{collection: string, actions: array<string>} 167 */ 168 protected function parseRepoScope(string $scope): array 169 { 170 $parts = explode('?', $scope, 2); 171 $collection = substr($parts[0], 5); // Remove 'repo:' 172 173 $actions = []; 174 if (isset($parts[1])) { 175 // Parse action=create&action=update&action=delete format 176 // PHP's parse_str doesn't handle repeated params well 177 preg_match_all('/action=([^&]+)/', $parts[1], $matches); 178 if (! empty($matches[1])) { 179 $actions = array_map('urldecode', $matches[1]); 180 } 181 } 182 183 return ['collection' => $collection, 'actions' => $actions]; 184 } 185 186 /** 187 * Check if a required collection matches a granted collection. 188 */ 189 protected function collectionsMatch(string $required, string $granted): bool 190 { 191 if ($granted === '*') { 192 return true; 193 } 194 195 return $required === $granted; 196 } 197 198 /** 199 * Check if the session has repo access for a specific collection and action. 200 */ 201 public function checkRepoScope(Session $session, string|BackedEnum $collection, string $action): bool 202 { 203 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 204 $required = "repo:{$collection}?action={$action}"; 205 206 return $this->sessionHasScope($session, $required); 207 } 208 209 /** 210 * Check repo scope and handle enforcement based on configuration. 211 * 212 * @throws MissingScopeException 213 */ 214 public function checkRepoScopeOrFail(Session $session, string|BackedEnum $collection, string $action): void 215 { 216 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 217 $required = "repo:{$collection}?action={$action}"; 218 219 $this->checkOrFail($session, [$required]); 220 } 221 222 /** 223 * Get the current enforcement level. 224 */ 225 public function enforcement(): ScopeEnforcementLevel 226 { 227 return $this->enforcement; 228 } 229 230 /** 231 * Create a new instance with a different enforcement level. 232 */ 233 public function withEnforcement(ScopeEnforcementLevel $enforcement): self 234 { 235 return new self($enforcement); 236 } 237 238 /** 239 * @param array<string|Scope> $scopes 240 * @return array<string> 241 */ 242 protected function normalizeScopes(array $scopes): array 243 { 244 return array_map( 245 fn ($scope) => $scope instanceof Scope ? $scope->value : $scope, 246 $scopes 247 ); 248 } 249 250 protected function sessionHasScope(Session $session, string $scope): bool 251 { 252 // Direct match 253 if ($session->hasScope($scope)) { 254 return true; 255 } 256 257 // Check granular pattern matching 258 return $this->matchesGranular($session, $scope); 259 } 260 261 protected function patternToRegex(string $pattern): string 262 { 263 // Escape regex special characters except * 264 $escaped = preg_quote($pattern, '/'); 265 266 // Replace \* with .* for wildcard matching 267 $regex = str_replace('\*', '.*', $escaped); 268 269 return '/^'.$regex.'$/'; 270 } 271}