+6
-2
src/Auth/DPoPKeyManager.php
+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
+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
+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
/**