Laravel AT Protocol Client (alpha & unstable)
at main 159 lines 5.1 kB view raw
1<?php 2 3namespace SocialDept\AtpClient\Auth; 4 5use Illuminate\Support\Str; 6use SocialDept\AtpClient\Data\AccessToken; 7use SocialDept\AtpClient\Data\AuthorizationRequest; 8use SocialDept\AtpClient\Data\DPoPKey; 9use SocialDept\AtpClient\Events\SessionAuthenticated; 10use SocialDept\AtpClient\Contracts\KeyStore; 11use SocialDept\AtpClient\Exceptions\AuthenticationException; 12use SocialDept\AtpClient\Http\DPoPClient; 13use SocialDept\AtpResolver\Facades\Resolver; 14 15class OAuthEngine 16{ 17 public function __construct( 18 protected DPoPKeyManager $dpopManager, 19 protected ClientMetadataManager $metadata, 20 protected DPoPClient $dpopClient, 21 protected ClientAssertionManager $clientAssertion, 22 protected KeyStore $keyStore, 23 ) {} 24 25 /** 26 * Initiate OAuth flow 27 */ 28 public function authorize( 29 string $identifier, 30 ?array $scopes = null, 31 ?string $pdsEndpoint = null 32 ): AuthorizationRequest { 33 // Use configured scopes if none provided 34 $scopes = $scopes ?? $this->metadata->getScopes(); 35 36 // Resolve PDS endpoint 37 if (! $pdsEndpoint) { 38 $pdsEndpoint = Resolver::resolvePds($identifier); 39 } 40 41 // Generate PKCE challenge 42 $codeVerifier = Str::random(128); 43 $codeChallenge = $this->generatePkceChallenge($codeVerifier); 44 45 // Generate state 46 $state = Str::random(32); 47 48 // Generate DPoP key for this flow 49 $dpopKey = $this->dpopManager->generateKey('oauth_'.$state); 50 51 // Build PAR request 52 $parResponse = $this->pushAuthorizationRequest( 53 $pdsEndpoint, 54 $scopes, 55 $codeChallenge, 56 $state, 57 $dpopKey 58 ); 59 60 // Build authorization URL 61 $authUrl = $pdsEndpoint.'/oauth/authorize?'.http_build_query([ 62 'request_uri' => $parResponse['request_uri'], 63 'client_id' => $this->metadata->getClientId(), 64 ]); 65 66 return new AuthorizationRequest( 67 url: $authUrl, 68 state: $state, 69 codeVerifier: $codeVerifier, 70 dpopKey: $dpopKey, 71 requestUri: $parResponse['request_uri'], 72 pdsEndpoint: $pdsEndpoint, 73 handle: $identifier, 74 ); 75 } 76 77 /** 78 * Complete OAuth flow with authorization code 79 */ 80 public function callback( 81 string $code, 82 string $state, 83 AuthorizationRequest $request 84 ): AccessToken { 85 if ($state !== $request->state) { 86 throw new AuthenticationException('State mismatch'); 87 } 88 89 $tokenUrl = $request->pdsEndpoint.'/oauth/token'; 90 91 $response = $this->dpopClient->request($request->pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey) 92 ->asForm() 93 ->post($tokenUrl, array_merge( 94 $this->clientAssertion->getAuthParams($request->pdsEndpoint), 95 [ 96 'grant_type' => 'authorization_code', 97 'code' => $code, 98 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 99 'code_verifier' => $request->codeVerifier, 100 ] 101 )); 102 103 if ($response->failed()) { 104 throw new AuthenticationException('Token exchange failed: '.$response->body()); 105 } 106 107 $token = AccessToken::fromResponse($response->json(), $request->handle, $request->pdsEndpoint); 108 109 // Store the DPoP key with the session ID so future requests can use it 110 // The token is bound to this key's thumbprint (cnf.jkt claim) 111 $sessionId = 'session_'.hash('sha256', $token->did); 112 $this->keyStore->store($sessionId, $request->dpopKey); 113 114 event(new SessionAuthenticated($token)); 115 116 return $token; 117 } 118 119 /** 120 * Push authorization request (PAR) 121 */ 122 protected function pushAuthorizationRequest( 123 string $pdsEndpoint, 124 array $scopes, 125 string $codeChallenge, 126 string $state, 127 DPoPKey $dpopKey 128 ): array { 129 $parUrl = $pdsEndpoint.'/oauth/par'; 130 131 $response = $this->dpopClient->request($pdsEndpoint, $parUrl, 'POST', $dpopKey) 132 ->asForm() 133 ->post($parUrl, array_merge( 134 $this->clientAssertion->getAuthParams($pdsEndpoint), 135 [ 136 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 137 'response_type' => 'code', 138 'scope' => implode(' ', $scopes), 139 'code_challenge' => $codeChallenge, 140 'code_challenge_method' => 'S256', 141 'state' => $state, 142 ] 143 )); 144 145 if ($response->failed()) { 146 throw new AuthenticationException('PAR failed: '.$response->body()); 147 } 148 149 return $response->json(); 150 } 151 152 /** 153 * Generate PKCE code challenge (S256) 154 */ 155 protected function generatePkceChallenge(string $verifier): string 156 { 157 return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); 158 } 159}