Laravel AT Protocol Client (alpha & unstable)

Extract DPoP HTTP middleware into reusable DPoPClient helper

+2 -4
src/AtpClient.php
··· 2 2 3 3 namespace SocialDept\AtpClient; 4 4 5 - use Illuminate\Http\Client\Factory; 6 - use SocialDept\AtpClient\Client\Client; 7 5 use SocialDept\AtpClient\Client\AtprotoClient; 8 6 use SocialDept\AtpClient\Client\BskyClient; 9 7 use SocialDept\AtpClient\Client\ChatClient; 8 + use SocialDept\AtpClient\Client\Client; 10 9 use SocialDept\AtpClient\Client\OzoneClient; 11 10 use SocialDept\AtpClient\Session\SessionManager; 12 11 ··· 39 38 40 39 public function __construct( 41 40 SessionManager $sessions, 42 - Factory $http, 43 41 string $identifier, 44 42 ) { 45 43 // Load the network client 46 - $this->client = new Client($this, $sessions, $http, $identifier); 44 + $this->client = new Client($this, $sessions, $identifier); 47 45 48 46 // Load all function collections 49 47 $this->bsky = new BskyClient($this);
+2 -2
src/AtpClientServiceProvider.php
··· 14 14 use SocialDept\AtpClient\Contracts\KeyStore; 15 15 use SocialDept\AtpClient\Http\Controllers\ClientMetadataController; 16 16 use SocialDept\AtpClient\Http\Controllers\JwksController; 17 + use SocialDept\AtpClient\Http\DPoPClient; 17 18 use SocialDept\AtpClient\Session\SessionManager; 18 19 use SocialDept\AtpClient\Storage\EncryptedFileKeyStore; 19 20 ··· 43 44 $this->app->singleton(ClientMetadataManager::class); 44 45 $this->app->singleton(DPoPKeyManager::class); 45 46 $this->app->singleton(DPoPNonceManager::class); 47 + $this->app->singleton(DPoPClient::class); 46 48 $this->app->singleton(TokenRefresher::class); 47 49 $this->app->singleton(SessionManager::class, function ($app) { 48 50 return new SessionManager( ··· 50 52 refresher: $app->make(TokenRefresher::class), 51 53 dpopManager: $app->make(DPoPKeyManager::class), 52 54 keyStore: $app->make(KeyStore::class), 53 - http: $app->make('http'), 54 55 refreshThreshold: config('client.session.refresh_threshold', 300), 55 56 ); 56 57 }); ··· 73 74 { 74 75 return new AtpClient( 75 76 $this->app->make(SessionManager::class), 76 - $this->app->make('http'), 77 77 $identifier 78 78 ); 79 79 }
+23 -112
src/Auth/OAuthEngine.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Auth; 4 4 5 - use Illuminate\Http\Client\Factory as HttpClient; 6 5 use Illuminate\Support\Str; 7 6 use SocialDept\AtpClient\Data\AccessToken; 8 7 use SocialDept\AtpClient\Data\AuthorizationRequest; 8 + use SocialDept\AtpClient\Data\DPoPKey; 9 9 use SocialDept\AtpClient\Exceptions\AuthenticationException; 10 + use SocialDept\AtpClient\Http\DPoPClient; 10 11 use SocialDept\Resolver\Facades\Resolver; 11 12 12 13 class OAuthEngine 13 14 { 14 15 public function __construct( 15 - protected HttpClient $http, 16 16 protected DPoPKeyManager $dpopManager, 17 17 protected ClientMetadataManager $metadata, 18 - protected DPoPNonceManager $nonceManager, 18 + protected DPoPClient $dpopClient, 19 19 ) {} 20 20 21 21 /** ··· 79 79 // Get PDS endpoint from request 80 80 $pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri); 81 81 $tokenUrl = $pdsEndpoint.'/oauth/token'; 82 - $tokenData = [ 83 - 'grant_type' => 'authorization_code', 84 - 'code' => $code, 85 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 86 - 'client_id' => $this->metadata->getClientId(), 87 - 'code_verifier' => $request->codeVerifier, 88 - ]; 89 82 90 - // Get cached nonce 91 - $nonce = $this->nonceManager->getNonce($pdsEndpoint); 92 - 93 - $dpopProof = $this->dpopManager->createProof( 94 - key: $request->dpopKey, 95 - method: 'POST', 96 - url: $tokenUrl, 97 - nonce: $nonce, 98 - ); 99 - 100 - $response = $this->http 101 - ->withHeaders([ 102 - 'DPoP' => $dpopProof, 103 - 'Content-Type' => 'application/x-www-form-urlencoded', 104 - ]) 83 + $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey) 105 84 ->asForm() 106 - ->post($tokenUrl, $tokenData); 107 - 108 - // Handle use_dpop_nonce error - retry with server-provided nonce 109 - if ($response->status() === 400) { 110 - $error = $response->json('error'); 111 - 112 - if ($error === 'use_dpop_nonce' && $response->header('DPoP-Nonce')) { 113 - $nonce = $response->header('DPoP-Nonce'); 114 - $this->nonceManager->storeNonce($pdsEndpoint, $nonce); 115 - 116 - // Retry with new nonce 117 - $dpopProof = $this->dpopManager->createProof( 118 - key: $request->dpopKey, 119 - method: 'POST', 120 - url: $tokenUrl, 121 - nonce: $nonce, 122 - ); 123 - 124 - $response = $this->http 125 - ->withHeaders([ 126 - 'DPoP' => $dpopProof, 127 - 'Content-Type' => 'application/x-www-form-urlencoded', 128 - ]) 129 - ->asForm() 130 - ->post($tokenUrl, $tokenData); 131 - } 132 - } 133 - 134 - // Store nonce from response for future requests 135 - if ($response->header('DPoP-Nonce')) { 136 - $this->nonceManager->storeNonce($pdsEndpoint, $response->header('DPoP-Nonce')); 137 - } 85 + ->post($tokenUrl, [ 86 + 'grant_type' => 'authorization_code', 87 + 'code' => $code, 88 + 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 89 + 'client_id' => $this->metadata->getClientId(), 90 + 'code_verifier' => $request->codeVerifier, 91 + ]); 138 92 139 93 if ($response->failed()) { 140 - throw new AuthenticationException( 141 - 'Token exchange failed: '.$response->body() 142 - ); 94 + throw new AuthenticationException('Token exchange failed: '.$response->body()); 143 95 } 144 96 145 97 return AccessToken::fromResponse($response->json()); ··· 152 104 string $pdsEndpoint, 153 105 array $scopes, 154 106 string $codeChallenge, 155 - $dpopKey 107 + DPoPKey $dpopKey 156 108 ): array { 157 109 $parUrl = $pdsEndpoint.'/oauth/par'; 158 - $parData = [ 159 - 'client_id' => $this->metadata->getClientId(), 160 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 161 - 'response_type' => 'code', 162 - 'scope' => implode(' ', $scopes), 163 - 'code_challenge' => $codeChallenge, 164 - 'code_challenge_method' => 'S256', 165 - 'state' => Str::random(32), 166 - ]; 167 110 168 - // Try with cached nonce first (may be empty on first request) 169 - $nonce = $this->nonceManager->getNonce($pdsEndpoint); 170 - 171 - $dpopProof = $this->dpopManager->createProof( 172 - key: $dpopKey, 173 - method: 'POST', 174 - url: $parUrl, 175 - nonce: $nonce, 176 - ); 177 - 178 - $response = $this->http 179 - ->withHeaders(['DPoP' => $dpopProof]) 111 + $response = $this->dpopClient->request($pdsEndpoint, $parUrl, 'POST', $dpopKey) 180 112 ->asForm() 181 - ->post($parUrl, $parData); 182 - 183 - // Handle use_dpop_nonce error - retry with server-provided nonce 184 - if ($response->status() === 400) { 185 - $error = $response->json('error'); 186 - 187 - if ($error === 'use_dpop_nonce' && $response->header('DPoP-Nonce')) { 188 - $nonce = $response->header('DPoP-Nonce'); 189 - $this->nonceManager->storeNonce($pdsEndpoint, $nonce); 190 - 191 - // Retry with new nonce 192 - $dpopProof = $this->dpopManager->createProof( 193 - key: $dpopKey, 194 - method: 'POST', 195 - url: $parUrl, 196 - nonce: $nonce, 197 - ); 198 - 199 - $response = $this->http 200 - ->withHeaders(['DPoP' => $dpopProof]) 201 - ->asForm() 202 - ->post($parUrl, $parData); 203 - } 204 - } 205 - 206 - // Store nonce from successful response for future requests 207 - if ($response->header('DPoP-Nonce')) { 208 - $this->nonceManager->storeNonce($pdsEndpoint, $response->header('DPoP-Nonce')); 209 - } 113 + ->post($parUrl, [ 114 + 'client_id' => $this->metadata->getClientId(), 115 + 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 116 + 'response_type' => 'code', 117 + 'scope' => implode(' ', $scopes), 118 + 'code_challenge' => $codeChallenge, 119 + 'code_challenge_method' => 'S256', 120 + 'state' => Str::random(32), 121 + ]); 210 122 211 123 if ($response->failed()) { 212 124 throw new AuthenticationException('PAR failed: '.$response->body()); ··· 228 140 */ 229 141 protected function extractPdsFromRequestUri(string $requestUri): string 230 142 { 231 - // Parse the request URI to extract the base PDS endpoint 232 143 $parts = parse_url($requestUri); 233 144 234 145 return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '');
+6 -25
src/Auth/TokenRefresher.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Auth; 4 4 5 - use Illuminate\Http\Client\Factory as HttpClient; 6 5 use SocialDept\AtpClient\Data\AccessToken; 7 6 use SocialDept\AtpClient\Data\DPoPKey; 8 7 use SocialDept\AtpClient\Exceptions\AuthenticationException; 8 + use SocialDept\AtpClient\Http\DPoPClient; 9 9 10 10 class TokenRefresher 11 11 { 12 12 public function __construct( 13 - protected HttpClient $http, 14 - protected DPoPKeyManager $dpopManager, 13 + protected DPoPClient $dpopClient, 15 14 ) {} 16 15 17 16 /** ··· 23 22 string $pdsEndpoint, 24 23 DPoPKey $dpopKey 25 24 ): AccessToken { 26 - $dpopProof = $this->dpopManager->createProof( 27 - key: $dpopKey, 28 - method: 'POST', 29 - url: $pdsEndpoint.'/oauth/token', 30 - nonce: $this->getDpopNonce($pdsEndpoint), 31 - ); 25 + $tokenUrl = $pdsEndpoint.'/oauth/token'; 32 26 33 - $response = $this->http 34 - ->withHeaders([ 35 - 'DPoP' => $dpopProof, 36 - 'Content-Type' => 'application/x-www-form-urlencoded', 37 - ]) 27 + $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $dpopKey) 38 28 ->asForm() 39 - ->post($pdsEndpoint.'/oauth/token', [ 29 + ->post($tokenUrl, [ 40 30 'grant_type' => 'refresh_token', 41 31 'refresh_token' => $refreshToken, 42 32 ]); 43 33 44 34 if ($response->failed()) { 45 - throw new AuthenticationException( 46 - 'Token refresh failed: '.$response->body() 47 - ); 35 + throw new AuthenticationException('Token refresh failed: '.$response->body()); 48 36 } 49 37 50 38 return AccessToken::fromResponse($response->json()); 51 - } 52 - 53 - protected function getDpopNonce(string $pdsEndpoint): string 54 - { 55 - // TODO: Implement proper DPoP nonce fetching and caching 56 - // For now, return a placeholder that will need to be replaced 57 - return 'temp-nonce-'.time(); 58 39 } 59 40 }
+2 -5
src/Client/Client.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Client; 4 4 5 - use Illuminate\Http\Client\Factory; 6 5 use SocialDept\AtpClient\AtpClient; 7 - use SocialDept\AtpClient\Auth\DPoPNonceManager; 6 + use SocialDept\AtpClient\Http\DPoPClient; 8 7 use SocialDept\AtpClient\Http\HasHttp; 9 8 use SocialDept\AtpClient\Session\SessionManager; 10 9 ··· 20 19 public function __construct( 21 20 AtpClient $parent, 22 21 SessionManager $sessions, 23 - Factory $http, 24 22 string $identifier, 25 23 ) { 26 24 $this->atp = $parent; 27 25 $this->sessions = $sessions; 28 - $this->http = $http; 29 26 $this->identifier = $identifier; 30 - $this->nonceManager = app(DPoPNonceManager::class); 27 + $this->dpopClient = app(DPoPClient::class); 31 28 } 32 29 }
+89
src/Http/DPoPClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http; 4 + 5 + use Illuminate\Http\Client\PendingRequest; 6 + use Illuminate\Support\Facades\Http; 7 + use Psr\Http\Message\RequestInterface; 8 + use Psr\Http\Message\ResponseInterface; 9 + use SocialDept\AtpClient\Auth\DPoPKeyManager; 10 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 11 + use SocialDept\AtpClient\Data\DPoPKey; 12 + 13 + class DPoPClient 14 + { 15 + public function __construct( 16 + protected DPoPKeyManager $dpopManager, 17 + protected DPoPNonceManager $nonceManager, 18 + ) {} 19 + 20 + /** 21 + * Build a DPoP-authenticated request with automatic nonce retry 22 + */ 23 + public function request( 24 + string $pdsEndpoint, 25 + string $url, 26 + string $method, 27 + DPoPKey $dpopKey, 28 + ?string $accessToken = null, 29 + ): PendingRequest { 30 + return Http::retry(times: 2, sleepMilliseconds: 0, throw: false) 31 + ->withRequestMiddleware( 32 + fn (RequestInterface $request) => $this->addDPoPProof( 33 + $request, 34 + $pdsEndpoint, 35 + $url, 36 + $method, 37 + $dpopKey, 38 + $accessToken, 39 + ) 40 + ) 41 + ->withResponseMiddleware( 42 + fn (ResponseInterface $response) => $this->captureNonce($response, $pdsEndpoint) 43 + ); 44 + } 45 + 46 + /** 47 + * Add DPoP proof header to request 48 + */ 49 + protected function addDPoPProof( 50 + RequestInterface $request, 51 + string $pdsEndpoint, 52 + string $url, 53 + string $method, 54 + DPoPKey $dpopKey, 55 + ?string $accessToken, 56 + ): RequestInterface { 57 + $nonce = $this->nonceManager->getNonce($pdsEndpoint); 58 + 59 + $dpopProof = $this->dpopManager->createProof( 60 + key: $dpopKey, 61 + method: $method, 62 + url: $url, 63 + nonce: $nonce, 64 + accessToken: $accessToken, 65 + ); 66 + 67 + $request = $request->withHeader('DPoP', $dpopProof); 68 + 69 + if ($accessToken) { 70 + $request = $request->withHeader('Authorization', 'Bearer '.$accessToken); 71 + } 72 + 73 + return $request; 74 + } 75 + 76 + /** 77 + * Capture DPoP nonce from response for future requests 78 + */ 79 + protected function captureNonce(ResponseInterface $response, string $pdsEndpoint): ResponseInterface 80 + { 81 + $nonce = $response->getHeaderLine('DPoP-Nonce'); 82 + 83 + if ($nonce !== '') { 84 + $this->nonceManager->storeNonce($pdsEndpoint, $nonce); 85 + } 86 + 87 + return $response; 88 + } 89 + }
+22 -62
src/Http/HasHttp.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Http; 4 4 5 - use Illuminate\Http\Client\Factory; 6 5 use Illuminate\Http\Client\Response as LaravelResponse; 7 6 use InvalidArgumentException; 8 - use SocialDept\AtpClient\Auth\DPoPKeyManager; 9 - use SocialDept\AtpClient\Auth\DPoPNonceManager; 10 7 use SocialDept\AtpClient\Exceptions\ValidationException; 8 + use SocialDept\AtpClient\Session\Session; 11 9 use SocialDept\AtpClient\Session\SessionManager; 12 10 use SocialDept\AtpSchema\Facades\Schema; 13 11 14 12 trait HasHttp 15 13 { 16 14 protected SessionManager $sessions; 17 - 18 - protected Factory $http; 19 15 20 16 protected string $identifier; 21 17 22 - protected DPoPNonceManager $nonceManager; 18 + protected DPoPClient $dpopClient; 23 19 24 20 /** 25 21 * Make XRPC call ··· 30 26 ?array $params = null, 31 27 ?array $body = null 32 28 ): Response { 33 - // Ensure session is valid (auto-refresh) 34 29 $session = $this->sessions->ensureValid($this->identifier); 35 - 36 - // Build URL 37 30 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 38 31 39 - // Get DPoP nonce 40 - $nonce = $this->nonceManager->getNonce($session->pdsEndpoint()); 41 - 42 - // Create DPoP proof using DPoPKeyManager 43 - $dpopProof = app(DPoPKeyManager::class)->createProof( 44 - key: $session->dpopKey(), 45 - method: $method, 46 - url: $url, 47 - nonce: $nonce, 48 - accessToken: $session->accessToken(), 49 - ); 50 - 51 - // Filter null parameters 52 32 $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); 53 33 54 - // Build request 55 - $request = $this->http 56 - ->withHeaders([ 57 - 'Authorization' => 'Bearer '.$session->accessToken(), 58 - 'DPoP' => $dpopProof, 59 - ]); 34 + $request = $this->buildAuthenticatedRequest($session, $url, $method); 60 35 61 - // Send request 62 36 $response = match ($method) { 63 37 'GET' => $request->get($url, $params), 64 38 'POST' => $request->post($url, $body ?? $params), ··· 66 40 default => throw new InvalidArgumentException("Unsupported method: {$method}"), 67 41 }; 68 42 69 - // Store nonce from response if present 70 - if ($newNonce = $response->header('DPoP-Nonce')) { 71 - $this->nonceManager->storeNonce($session->pdsEndpoint(), $newNonce); 72 - } 73 - 74 - // Validate response if schema exists 75 43 if (Schema::exists($endpoint)) { 76 44 $this->validateResponse($endpoint, $response); 77 45 } ··· 80 48 } 81 49 82 50 /** 51 + * Build authenticated request with DPoP proof and automatic nonce retry 52 + */ 53 + protected function buildAuthenticatedRequest( 54 + Session $session, 55 + string $url, 56 + string $method 57 + ): \Illuminate\Http\Client\PendingRequest { 58 + return $this->dpopClient->request( 59 + pdsEndpoint: $session->pdsEndpoint(), 60 + url: $url, 61 + method: $method, 62 + dpopKey: $session->dpopKey(), 63 + accessToken: $session->accessToken(), 64 + ); 65 + } 66 + 67 + /** 83 68 * Validate response against schema 84 69 */ 85 70 protected function validateResponse(string $endpoint, LaravelResponse $response): void 86 71 { 87 72 if (! $response->successful()) { 88 - return; // Don't validate error responses 73 + return; 89 74 } 90 75 91 76 $data = $response->json(); ··· 125 110 */ 126 111 protected function postBlob(string $endpoint, string $data, string $mimeType): Response 127 112 { 128 - // Ensure session is valid (auto-refresh) 129 113 $session = $this->sessions->ensureValid($this->identifier); 130 - 131 - // Build URL 132 114 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 133 115 134 - // Get DPoP nonce 135 - $nonce = $this->nonceManager->getNonce($session->pdsEndpoint()); 136 - 137 - // Create DPoP proof using DPoPKeyManager 138 - $dpopProof = app(DPoPKeyManager::class)->createProof( 139 - key: $session->dpopKey(), 140 - method: 'POST', 141 - url: $url, 142 - nonce: $nonce, 143 - accessToken: $session->accessToken(), 144 - ); 145 - 146 - // Build and send request with raw binary body 147 - $response = $this->http 148 - ->withHeaders([ 149 - 'Authorization' => 'Bearer '.$session->accessToken(), 150 - 'DPoP' => $dpopProof, 151 - ]) 116 + $response = $this->buildAuthenticatedRequest($session, $url, 'POST') 152 117 ->withBody($data, $mimeType) 153 118 ->post($url); 154 - 155 - // Store nonce from response if present 156 - if ($newNonce = $response->header('DPoP-Nonce')) { 157 - $this->nonceManager->storeNonce($session->pdsEndpoint(), $newNonce); 158 - } 159 119 160 120 return new Response($response); 161 121 }
+3 -4
src/Session/SessionManager.php
··· 2 2 3 3 namespace SocialDept\AtpClient\Session; 4 4 5 - use Illuminate\Http\Client\Factory as HttpClient; 5 + use Illuminate\Support\Facades\Http; 6 6 use SocialDept\AtpClient\Auth\DPoPKeyManager; 7 7 use SocialDept\AtpClient\Auth\TokenRefresher; 8 8 use SocialDept\AtpClient\Contracts\CredentialProvider; ··· 12 12 use SocialDept\AtpClient\Events\TokenRefreshing; 13 13 use SocialDept\AtpClient\Exceptions\AuthenticationException; 14 14 use SocialDept\AtpClient\Exceptions\SessionExpiredException; 15 - use SocialDept\AtpResolver\Facades\Resolver; 15 + use SocialDept\Resolver\Facades\Resolver; 16 16 17 17 class SessionManager 18 18 { ··· 23 23 protected TokenRefresher $refresher, 24 24 protected DPoPKeyManager $dpopManager, 25 25 protected KeyStore $keyStore, 26 - protected HttpClient $http, 27 26 protected int $refreshThreshold = 300, // 5 minutes 28 27 ) {} 29 28 ··· 63 62 ): Session { 64 63 $pdsEndpoint = Resolver::resolvePds($identifier); 65 64 66 - $response = $this->http->post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [ 65 + $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [ 67 66 'identifier' => $identifier, 68 67 'password' => $password, 69 68 ]);