+2
-4
src/AtpClient.php
+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
+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
+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
+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
-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
+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
+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
+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
]);