Laravel AT Protocol Client (alpha & unstable)
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}