Laravel AT Protocol Client (alpha & unstable)

Retry request if DPoP nonce is empty

+6 -2
src/Auth/DPoPKeyManager.php
··· 38 38 DPoPKey $key, 39 39 string $method, 40 40 string $url, 41 - string $nonce, 41 + string $nonce = '', 42 42 ?string $accessToken = null 43 43 ): string { 44 44 $now = time(); ··· 49 49 'htu' => $url, 50 50 'iat' => $now, 51 51 'exp' => $now + 60, // 1 minute validity 52 - 'nonce' => $nonce, 53 52 ]; 53 + 54 + // Only include nonce if provided (first request may not have one) 55 + if ($nonce !== '') { 56 + $payload['nonce'] = $nonce; 57 + } 54 58 55 59 if ($accessToken) { 56 60 $payload['ath'] = $this->hashAccessToken($accessToken);
+7 -29
src/Auth/DPoPNonceManager.php
··· 8 8 { 9 9 /** 10 10 * Get DPoP nonce for PDS endpoint 11 + * 12 + * Returns cached nonce if available, otherwise empty string. 13 + * The first request will fail with use_dpop_nonce error, 14 + * and the server will provide a valid nonce in the response. 11 15 */ 12 16 public function getNonce(string $pdsEndpoint): string 13 17 { 14 18 $cacheKey = 'dpop_nonce:'.md5($pdsEndpoint); 15 19 16 - // Return cached nonce if available 17 - if ($nonce = Cache::get($cacheKey)) { 18 - return $nonce; 19 - } 20 - 21 - // Fetch new nonce from server 22 - $nonce = $this->fetchNonce($pdsEndpoint); 23 - 24 - // Cache for 5 minutes 25 - Cache::put($cacheKey, $nonce, now()->addMinutes(5)); 26 - 27 - return $nonce; 20 + // Return cached nonce if available, empty string otherwise 21 + // Empty nonce triggers use_dpop_nonce error, which is expected 22 + return Cache::get($cacheKey, ''); 28 23 } 29 24 30 25 /** ··· 43 38 { 44 39 $cacheKey = 'dpop_nonce:'.md5($pdsEndpoint); 45 40 Cache::forget($cacheKey); 46 - } 47 - 48 - /** 49 - * Fetch nonce from PDS server 50 - */ 51 - protected function fetchNonce(string $pdsEndpoint): string 52 - { 53 - // Make a HEAD request to get initial nonce 54 - // The server returns nonce in DPoP-Nonce header 55 - try { 56 - $response = app('http')->head($pdsEndpoint.'/xrpc/_health'); 57 - 58 - return $response->header('DPoP-Nonce') ?? 'fallback-nonce-'.time(); 59 - } catch (\Exception $e) { 60 - // Fallback if health endpoint fails 61 - return 'fallback-nonce-'.time(); 62 - } 63 41 } 64 42 }
+92 -32
src/Auth/OAuthEngine.php
··· 7 7 use SocialDept\AtpClient\Data\AccessToken; 8 8 use SocialDept\AtpClient\Data\AuthorizationRequest; 9 9 use SocialDept\AtpClient\Exceptions\AuthenticationException; 10 - use SocialDept\AtpResolver\Facades\Resolver; 10 + use SocialDept\Resolver\Facades\Resolver; 11 11 12 12 class OAuthEngine 13 13 { ··· 15 15 protected HttpClient $http, 16 16 protected DPoPKeyManager $dpopManager, 17 17 protected ClientMetadataManager $metadata, 18 + protected DPoPNonceManager $nonceManager, 18 19 ) {} 19 20 20 21 /** ··· 77 78 78 79 // Get PDS endpoint from request 79 80 $pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri); 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 + 90 + // Get cached nonce 91 + $nonce = $this->nonceManager->getNonce($pdsEndpoint); 80 92 81 - // Exchange code for token 82 93 $dpopProof = $this->dpopManager->createProof( 83 94 key: $request->dpopKey, 84 95 method: 'POST', 85 - url: $pdsEndpoint.'/oauth/token', 86 - nonce: $this->getDpopNonce($pdsEndpoint), 96 + url: $tokenUrl, 97 + nonce: $nonce, 87 98 ); 88 99 89 100 $response = $this->http ··· 92 103 'Content-Type' => 'application/x-www-form-urlencoded', 93 104 ]) 94 105 ->asForm() 95 - ->post($pdsEndpoint.'/oauth/token', [ 96 - 'grant_type' => 'authorization_code', 97 - 'code' => $code, 98 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 99 - 'client_id' => $this->metadata->getClientId(), 100 - 'code_verifier' => $request->codeVerifier, 101 - ]); 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 + } 102 138 103 139 if ($response->failed()) { 104 140 throw new AuthenticationException( ··· 118 154 string $codeChallenge, 119 155 $dpopKey 120 156 ): array { 157 + $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 + 168 + // Try with cached nonce first (may be empty on first request) 169 + $nonce = $this->nonceManager->getNonce($pdsEndpoint); 170 + 121 171 $dpopProof = $this->dpopManager->createProof( 122 172 key: $dpopKey, 123 173 method: 'POST', 124 - url: $pdsEndpoint.'/oauth/par', 125 - nonce: $this->getDpopNonce($pdsEndpoint), 174 + url: $parUrl, 175 + nonce: $nonce, 126 176 ); 127 177 128 178 $response = $this->http 129 179 ->withHeaders(['DPoP' => $dpopProof]) 130 180 ->asForm() 131 - ->post($pdsEndpoint.'/oauth/par', [ 132 - 'client_id' => $this->metadata->getClientId(), 133 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 134 - 'response_type' => 'code', 135 - 'scope' => implode(' ', $scopes), 136 - 'code_challenge' => $codeChallenge, 137 - 'code_challenge_method' => 'S256', 138 - 'state' => Str::random(32), 139 - ]); 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 + } 140 210 141 211 if ($response->failed()) { 142 212 throw new AuthenticationException('PAR failed: '.$response->body()); ··· 151 221 protected function generatePkceChallenge(string $verifier): string 152 222 { 153 223 return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); 154 - } 155 - 156 - /** 157 - * Get DPoP nonce from server 158 - */ 159 - protected function getDpopNonce(string $pdsEndpoint): string 160 - { 161 - // TODO: Implement proper DPoP nonce fetching and caching 162 - // This is typically returned in DPoP-Nonce header 163 - return 'temp-nonce-'.time(); 164 224 } 165 225 166 226 /**