Laravel AT Protocol Client (alpha & unstable)
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}