Laravel AT Protocol Client (alpha & unstable)
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Phase 2: Authentication & DPoP

+428
+66
src/Auth/ClientMetadataManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + class ClientMetadataManager 6 + { 7 + /** 8 + * Get the client ID (typically the client URL) 9 + */ 10 + public function getClientId(): string 11 + { 12 + return config('atp-client.client.url'); 13 + } 14 + 15 + /** 16 + * Get the client metadata URL 17 + */ 18 + public function getMetadataUrl(): ?string 19 + { 20 + return config('atp-client.client.metadata_url'); 21 + } 22 + 23 + /** 24 + * Get the redirect URIs 25 + * 26 + * @return array<string> 27 + */ 28 + public function getRedirectUris(): array 29 + { 30 + return config('atp-client.client.redirect_uris', []); 31 + } 32 + 33 + /** 34 + * Get the OAuth scopes 35 + * 36 + * @return array<string> 37 + */ 38 + public function getScopes(): array 39 + { 40 + return config('atp-client.client.scopes', ['atproto', 'transition:generic']); 41 + } 42 + 43 + /** 44 + * Get the client metadata as an array 45 + * 46 + * @return array<string, mixed> 47 + */ 48 + public function toArray(): array 49 + { 50 + return [ 51 + 'client_id' => $this->getClientId(), 52 + 'client_name' => config('atp-client.client.name'), 53 + 'client_uri' => config('atp-client.client.url'), 54 + 'redirect_uris' => $this->getRedirectUris(), 55 + 'scope' => implode(' ', $this->getScopes()), 56 + 'grant_types' => [ 57 + 'authorization_code', 58 + 'refresh_token', 59 + ], 60 + 'response_types' => ['code'], 61 + 'token_endpoint_auth_method' => 'none', 62 + 'application_type' => 'web', 63 + 'dpop_bound_access_tokens' => true, 64 + ]; 65 + } 66 + }
+106
src/Auth/DPoPKeyManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use Jose\Component\Core\AlgorithmManager; 6 + use Jose\Component\Core\JWK; 7 + use Jose\Component\KeyManagement\JWKFactory; 8 + use Jose\Component\Signature\Algorithm\ES256; 9 + use Jose\Component\Signature\JWSBuilder; 10 + use Jose\Component\Signature\Serializer\CompactSerializer; 11 + use SocialDept\AtpClient\Contracts\KeyStore; 12 + use SocialDept\AtpClient\Data\DPoPKey; 13 + 14 + class DPoPKeyManager 15 + { 16 + protected AlgorithmManager $algorithmManager; 17 + 18 + protected JWSBuilder $jwsBuilder; 19 + 20 + public function __construct( 21 + protected KeyStore $keyStore 22 + ) { 23 + $this->algorithmManager = new AlgorithmManager([new ES256()]); 24 + $this->jwsBuilder = new JWSBuilder($this->algorithmManager); 25 + } 26 + 27 + /** 28 + * Generate new ES256 key pair 29 + */ 30 + public function generateKey(string $sessionId): DPoPKey 31 + { 32 + // Generate P-256 elliptic curve key pair 33 + $privateKey = JWKFactory::createECKey('P-256', [ 34 + 'use' => 'sig', 35 + 'alg' => 'ES256', 36 + ]); 37 + 38 + $publicKey = $privateKey->toPublic(); 39 + $keyId = $this->generateKeyId($publicKey); 40 + 41 + $dpopKey = new DPoPKey($privateKey, $publicKey, $keyId); 42 + 43 + // Store the key 44 + $this->keyStore->store($sessionId, $dpopKey); 45 + 46 + return $dpopKey; 47 + } 48 + 49 + /** 50 + * Create DPoP proof JWT 51 + */ 52 + public function createProof( 53 + DPoPKey $key, 54 + string $method, 55 + string $url, 56 + string $nonce, 57 + ?string $accessToken = null 58 + ): string { 59 + $now = time(); 60 + 61 + $payload = [ 62 + 'jti' => bin2hex(random_bytes(16)), 63 + 'htm' => $method, 64 + 'htu' => $url, 65 + 'iat' => $now, 66 + 'exp' => $now + 60, // 1 minute validity 67 + 'nonce' => $nonce, 68 + ]; 69 + 70 + if ($accessToken) { 71 + $payload['ath'] = $this->hashAccessToken($accessToken); 72 + } 73 + 74 + $header = [ 75 + 'typ' => 'dpop+jwt', 76 + 'alg' => 'ES256', 77 + 'jwk' => $key->getPublicJwk(), 78 + ]; 79 + 80 + $jws = $this->jwsBuilder 81 + ->create() 82 + ->withPayload(json_encode($payload)) 83 + ->addSignature($key->privateKey, $header) 84 + ->build(); 85 + 86 + $serializer = new CompactSerializer(); 87 + 88 + return $serializer->serialize($jws, 0); 89 + } 90 + 91 + /** 92 + * Hash access token for DPoP proof 93 + */ 94 + protected function hashAccessToken(string $token): string 95 + { 96 + return rtrim(strtr(base64_encode(hash('sha256', $token, true)), '+/', '-_'), '='); 97 + } 98 + 99 + /** 100 + * Generate key ID from public key 101 + */ 102 + protected function generateKeyId(JWK $publicKey): string 103 + { 104 + return hash('sha256', json_encode($publicKey->jsonSerialize())); 105 + } 106 + }
+176
src/Auth/OAuthEngine.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use Illuminate\Http\Client\Factory as HttpClient; 6 + use Illuminate\Support\Str; 7 + use SocialDept\AtpClient\Data\AccessToken; 8 + use SocialDept\AtpClient\Data\AuthorizationRequest; 9 + use SocialDept\AtpClient\Exceptions\AuthenticationException; 10 + use SocialDept\AtpResolver\Facades\Resolver; 11 + 12 + class OAuthEngine 13 + { 14 + public function __construct( 15 + protected HttpClient $http, 16 + protected DPoPKeyManager $dpopManager, 17 + protected ClientMetadataManager $metadata, 18 + ) {} 19 + 20 + /** 21 + * Initiate OAuth flow 22 + */ 23 + public function authorize( 24 + string $identifier, 25 + array $scopes = ['atproto', 'transition:generic'], 26 + ?string $pdsEndpoint = null 27 + ): AuthorizationRequest { 28 + // Resolve PDS endpoint 29 + if (! $pdsEndpoint) { 30 + $pdsEndpoint = Resolver::resolvePds($identifier); 31 + } 32 + 33 + // Generate PKCE challenge 34 + $codeVerifier = Str::random(128); 35 + $codeChallenge = $this->generatePkceChallenge($codeVerifier); 36 + 37 + // Generate state 38 + $state = Str::random(32); 39 + 40 + // Generate DPoP key for this flow 41 + $dpopKey = $this->dpopManager->generateKey('oauth_'.$state); 42 + 43 + // Build PAR request 44 + $parResponse = $this->pushAuthorizationRequest( 45 + $pdsEndpoint, 46 + $scopes, 47 + $codeChallenge, 48 + $dpopKey 49 + ); 50 + 51 + // Build authorization URL 52 + $authUrl = $pdsEndpoint.'/oauth/authorize?'.http_build_query([ 53 + 'request_uri' => $parResponse['request_uri'], 54 + 'client_id' => $this->metadata->getClientId(), 55 + ]); 56 + 57 + return new AuthorizationRequest( 58 + url: $authUrl, 59 + state: $state, 60 + codeVerifier: $codeVerifier, 61 + dpopKey: $dpopKey, 62 + requestUri: $parResponse['request_uri'], 63 + ); 64 + } 65 + 66 + /** 67 + * Complete OAuth flow with authorization code 68 + */ 69 + public function callback( 70 + string $code, 71 + string $state, 72 + AuthorizationRequest $request 73 + ): AccessToken { 74 + if ($state !== $request->state) { 75 + throw new AuthenticationException('State mismatch'); 76 + } 77 + 78 + // Get PDS endpoint from request 79 + $pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri); 80 + 81 + // Exchange code for token 82 + $dpopProof = $this->dpopManager->createProof( 83 + key: $request->dpopKey, 84 + method: 'POST', 85 + url: $pdsEndpoint.'/oauth/token', 86 + nonce: $this->getDpopNonce($pdsEndpoint), 87 + ); 88 + 89 + $response = $this->http 90 + ->withHeaders([ 91 + 'DPoP' => $dpopProof, 92 + 'Content-Type' => 'application/x-www-form-urlencoded', 93 + ]) 94 + ->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 + ]); 102 + 103 + if ($response->failed()) { 104 + throw new AuthenticationException( 105 + 'Token exchange failed: '.$response->body() 106 + ); 107 + } 108 + 109 + return AccessToken::fromResponse($response->json()); 110 + } 111 + 112 + /** 113 + * Push authorization request (PAR) 114 + */ 115 + protected function pushAuthorizationRequest( 116 + string $pdsEndpoint, 117 + array $scopes, 118 + string $codeChallenge, 119 + $dpopKey 120 + ): array { 121 + $dpopProof = $this->dpopManager->createProof( 122 + key: $dpopKey, 123 + method: 'POST', 124 + url: $pdsEndpoint.'/oauth/par', 125 + nonce: $this->getDpopNonce($pdsEndpoint), 126 + ); 127 + 128 + $response = $this->http 129 + ->withHeaders(['DPoP' => $dpopProof]) 130 + ->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 + ]); 140 + 141 + if ($response->failed()) { 142 + throw new AuthenticationException('PAR failed: '.$response->body()); 143 + } 144 + 145 + return $response->json(); 146 + } 147 + 148 + /** 149 + * Generate PKCE code challenge (S256) 150 + */ 151 + protected function generatePkceChallenge(string $verifier): string 152 + { 153 + 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 + } 165 + 166 + /** 167 + * Extract PDS endpoint from request URI 168 + */ 169 + protected function extractPdsFromRequestUri(string $requestUri): string 170 + { 171 + // Parse the request URI to extract the base PDS endpoint 172 + $parts = parse_url($requestUri); 173 + 174 + return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? ''); 175 + } 176 + }
+59
src/Auth/TokenRefresher.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use Illuminate\Http\Client\Factory as HttpClient; 6 + use SocialDept\AtpClient\Data\AccessToken; 7 + use SocialDept\AtpClient\Data\DPoPKey; 8 + use SocialDept\AtpClient\Exceptions\AuthenticationException; 9 + 10 + class TokenRefresher 11 + { 12 + public function __construct( 13 + protected HttpClient $http, 14 + protected DPoPKeyManager $dpopManager, 15 + ) {} 16 + 17 + /** 18 + * Refresh access token using refresh token 19 + * NOTE: Refresh tokens are single-use! 20 + */ 21 + public function refresh( 22 + string $refreshToken, 23 + string $pdsEndpoint, 24 + DPoPKey $dpopKey 25 + ): AccessToken { 26 + $dpopProof = $this->dpopManager->createProof( 27 + key: $dpopKey, 28 + method: 'POST', 29 + url: $pdsEndpoint.'/oauth/token', 30 + nonce: $this->getDpopNonce($pdsEndpoint), 31 + ); 32 + 33 + $response = $this->http 34 + ->withHeaders([ 35 + 'DPoP' => $dpopProof, 36 + 'Content-Type' => 'application/x-www-form-urlencoded', 37 + ]) 38 + ->asForm() 39 + ->post($pdsEndpoint.'/oauth/token', [ 40 + 'grant_type' => 'refresh_token', 41 + 'refresh_token' => $refreshToken, 42 + ]); 43 + 44 + if ($response->failed()) { 45 + throw new AuthenticationException( 46 + 'Token refresh failed: '.$response->body() 47 + ); 48 + } 49 + 50 + 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 + } 59 + }
+14
src/Data/AuthorizationRequest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Data; 4 + 5 + class AuthorizationRequest 6 + { 7 + public function __construct( 8 + public readonly string $url, 9 + public readonly string $state, 10 + public readonly string $codeVerifier, 11 + public readonly DPoPKey $dpopKey, 12 + public readonly string $requestUri, 13 + ) {} 14 + }
+7
src/Exceptions/AuthenticationException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Exceptions; 4 + 5 + class AuthenticationException extends \Exception 6 + { 7 + }