Something went wrong. Try again.
Laravel AT Protocol Client (alpha & unstable)
Something went wrong. Try again.
8.2 kB · 271 lines
PHP
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272<?php
namespace SocialDept\AtpClient\Auth;
use BackedEnum;use Illuminate\Support\Facades\Log;use SocialDept\AtpClient\Enums\Scope;use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;use SocialDept\AtpClient\Exceptions\MissingScopeException;use SocialDept\AtpClient\Session\Session;
class ScopeChecker{ public function __construct( protected ScopeEnforcementLevel $enforcement = ScopeEnforcementLevel::Permissive ) {}
/** * Check if the session has all required scopes. * * @param array<string|Scope> $requiredScopes */ public function check(Session $session, array $requiredScopes): bool { $required = $this->normalizeScopes($requiredScopes); $granted = $session->scopes();
foreach ($required as $scope) { if (! $this->sessionHasScope($session, $scope)) { return false; } }
return true; }
/** * Check scopes and handle enforcement based on configuration. * * @param array<string|Scope> $requiredScopes * * @throws MissingScopeException */ public function checkOrFail(Session $session, array $requiredScopes): void { if ($this->check($session, $requiredScopes)) { return; }
$required = $this->normalizeScopes($requiredScopes); $granted = $session->scopes(); $missing = array_diff($required, $granted);
if ($this->enforcement === ScopeEnforcementLevel::Strict) { throw new MissingScopeException($missing, $granted); }
Log::warning('ATP Client: Missing required scope(s)', [ 'required' => $required, 'granted' => $granted, 'missing' => $missing, 'did' => $session->did(), ]); }
/** * Check if the session has a specific scope. */ public function hasScope(Session $session, string|Scope $scope): bool { $scope = $scope instanceof Scope ? $scope->value : $scope;
return $this->sessionHasScope($session, $scope); }
/** * Check if the session matches a granular scope pattern. * * Supports patterns like: * - repo:app.bsky.feed.post?action=create * - repo:app.bsky.feed.* * - rpc:app.bsky.feed.* * - blob:image/* */ public function matchesGranular(Session $session, string $pattern): bool { $granted = $session->scopes();
// Check for exact match first if (in_array($pattern, $granted, true)) { return true; }
// Handle repo: scopes with action semantics if (str_starts_with($pattern, 'repo:')) { foreach ($granted as $scope) { if (str_starts_with($scope, 'repo:') && $this->matchesRepoScope($pattern, $scope)) { return true; } } }
// Check for wildcard matches $patternRegex = $this->patternToRegex($pattern);
foreach ($granted as $scope) { if (preg_match($patternRegex, $scope)) { return true; } }
// Check if granted scope is a superset (wildcard in granted scope) foreach ($granted as $scope) { $grantedRegex = $this->patternToRegex($scope); if (preg_match($grantedRegex, $pattern)) { return true; } }
return false; }
/** * Check if a required repo scope is satisfied by a granted repo scope. * * Per AT Protocol spec: "If not defined, all operations are allowed." * - repo:collection (no action) grants ALL actions * - repo:collection?action=create grants only create * - repo:* grants all collections with all actions */ protected function matchesRepoScope(string $required, string $granted): bool { $requiredParsed = $this->parseRepoScope($required); $grantedParsed = $this->parseRepoScope($granted);
// Check collection match (with wildcard support) if (! $this->collectionsMatch($requiredParsed['collection'], $grantedParsed['collection'])) { return false; }
// If granted has no actions, it grants ALL actions if (empty($grantedParsed['actions'])) { return true; }
// If required has no actions, we need all actions granted if (empty($requiredParsed['actions'])) { // Required needs all actions, but granted is restricted return false; }
// Check if all required actions are in granted actions return empty(array_diff($requiredParsed['actions'], $grantedParsed['actions'])); }
/** * Parse a repo scope into collection and actions. * * Handles formats like: * - repo:app.bsky.feed.post * - repo:app.bsky.feed.post?action=create * - repo:app.bsky.feed.post?action=create&action=update&action=delete * - repo:* * - repo:*?action=delete * * @return array{collection: string, actions: array<string>} */ protected function parseRepoScope(string $scope): array { $parts = explode('?', $scope, 2); $collection = substr($parts[0], 5); // Remove 'repo:'
$actions = []; if (isset($parts[1])) { // Parse action=create&action=update&action=delete format // PHP's parse_str doesn't handle repeated params well preg_match_all('/action=([^&]+)/', $parts[1], $matches); if (! empty($matches[1])) { $actions = array_map('urldecode', $matches[1]); } }
return ['collection' => $collection, 'actions' => $actions]; }
/** * Check if a required collection matches a granted collection. */ protected function collectionsMatch(string $required, string $granted): bool { if ($granted === '*') { return true; }
return $required === $granted; }
/** * Check if the session has repo access for a specific collection and action. */ public function checkRepoScope(Session $session, string|BackedEnum $collection, string $action): bool { $collection = $collection instanceof BackedEnum ? $collection->value : $collection; $required = "repo:{$collection}?action={$action}";
return $this->sessionHasScope($session, $required); }
/** * Check repo scope and handle enforcement based on configuration. * * @throws MissingScopeException */ public function checkRepoScopeOrFail(Session $session, string|BackedEnum $collection, string $action): void { $collection = $collection instanceof BackedEnum ? $collection->value : $collection; $required = "repo:{$collection}?action={$action}";
$this->checkOrFail($session, [$required]); }
/** * Get the current enforcement level. */ public function enforcement(): ScopeEnforcementLevel { return $this->enforcement; }
/** * Create a new instance with a different enforcement level. */ public function withEnforcement(ScopeEnforcementLevel $enforcement): self { return new self($enforcement); }
/** * @param array<string|Scope> $scopes * @return array<string> */ protected function normalizeScopes(array $scopes): array { return array_map( fn ($scope) => $scope instanceof Scope ? $scope->value : $scope, $scopes ); }
protected function sessionHasScope(Session $session, string $scope): bool { // Direct match if ($session->hasScope($scope)) { return true; }
// Check granular pattern matching return $this->matchesGranular($session, $scope); }
protected function patternToRegex(string $pattern): string { // Escape regex special characters except * $escaped = preg_quote($pattern, '/');
// Replace \* with .* for wildcard matching $regex = str_replace('\*', '.*', $escaped);
return '/^'.$regex.'$/'; }}