Skip to content

Commit ce2d2f6

Browse files
committed
Added automatic refresh for expired tokens
1 parent a85c090 commit ce2d2f6

8 files changed

Lines changed: 711 additions & 0 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ $sdk = new GeocachingSdk($options);
7878

7979
// All API calls will now be logged
8080
$response = $sdk->ping();
81+
82+
// Optional: Enable automatic token refresh
83+
use League\OAuth2\Client\Provider\Geocaching;
84+
85+
$oauthProvider = new Geocaching([
86+
'clientId' => 'your_client_id',
87+
'clientSecret' => 'your_client_secret',
88+
'redirectUri' => 'https://your-app.com/callback',
89+
'environment' => 'production',
90+
]);
91+
92+
$options->enableTokenRefresh([
93+
'user_id' => 'user_123',
94+
'storage' => $tokenStorage, // Your TokenStorageInterface implementation
95+
'oauth_provider' => $oauthProvider,
96+
]);
8197
```
8298

8399
### Configuration Options
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Geocaching\Exception;
6+
7+
/**
8+
* Exception thrown when the refresh token is expired or invalid.
9+
*
10+
* This indicates that the user must re-authenticate to obtain new tokens.
11+
*/
12+
class RefreshTokenExpiredException extends TokenRefreshException
13+
{
14+
public function __construct(
15+
string $message = 'Refresh token has expired. User must re-authenticate.',
16+
int $code = 401,
17+
?\Throwable $previous = null
18+
) {
19+
parent::__construct($message, $code, $previous);
20+
}
21+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Geocaching\Exception;
6+
7+
use Exception;
8+
use Throwable;
9+
10+
/**
11+
* Exception thrown when token refresh fails.
12+
*/
13+
class TokenRefreshException extends Exception
14+
{
15+
public function __construct(
16+
string $message = 'Failed to refresh access token',
17+
int $code = 0,
18+
?Throwable $previous = null,
19+
private ?array $responseData = null
20+
) {
21+
parent::__construct($message, $code, $previous);
22+
}
23+
24+
/**
25+
* Get the response data from the OAuth server (if available).
26+
*
27+
* @return array|null
28+
*/
29+
public function getResponseData(): ?array
30+
{
31+
return $this->responseData;
32+
}
33+
34+
/**
35+
* Create exception from OAuth error response.
36+
*
37+
* @param array $responseData
38+
* @param int $httpCode
39+
* @param Throwable|null $previous
40+
* @return self
41+
*/
42+
public static function fromOAuthResponse(array $responseData, int $httpCode = 0, ?Throwable $previous = null): self
43+
{
44+
$error = $responseData['error'] ?? 'unknown_error';
45+
$description = $responseData['error_description'] ?? 'Token refresh failed';
46+
47+
$message = "OAuth error '{$error}': {$description}";
48+
49+
return new self($message, $httpCode, $previous, $responseData);
50+
}
51+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Geocaching\Exception;
6+
7+
use Exception;
8+
9+
/**
10+
* Exception thrown when token storage operations fail.
11+
*/
12+
class TokenStorageException extends Exception
13+
{
14+
public function __construct(
15+
string $message = 'Token storage operation failed',
16+
int $code = 0,
17+
?\Throwable $previous = null
18+
) {
19+
parent::__construct($message, $code, $previous);
20+
}
21+
}

src/Options.php

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
use Geocaching\Enum\BaseUri;
88
use Geocaching\Enum\Environment;
99
use Geocaching\Plugin\GeocachingHttpLoggerPlugin;
10+
use Geocaching\Plugin\TokenRefreshPlugin;
11+
use Geocaching\Token\TokenStorageInterface;
12+
use League\OAuth2\Client\Provider\Geocaching;
1013
use Http\Client\Common\Plugin\AuthenticationPlugin;
1114
use Http\Client\Common\Plugin\BaseUriPlugin;
1215
use Http\Discovery\Psr17FactoryDiscovery;
@@ -185,4 +188,154 @@ private function createConfiguredLogger(string $output, string $level, ?string $
185188

186189
return $logger;
187190
}
191+
192+
/**
193+
* Enable automatic token refresh when access tokens expire.
194+
*
195+
* This adds a TokenRefreshPlugin that will automatically refresh expired access tokens
196+
* using the provided OAuth provider and storage implementation.
197+
*
198+
* @param array $config Configuration array with the following keys:
199+
* - user_id: User identifier for token storage
200+
* - storage: TokenStorageInterface implementation
201+
* - oauth_provider: Geocaching OAuth provider instance
202+
* - logger: PSR-3 logger for token refresh events (optional)
203+
* - max_retry_attempts: Maximum retry attempts (optional, default: 3)
204+
* @return void
205+
* @throws \InvalidArgumentException If required configuration is missing
206+
*/
207+
public function enableTokenRefresh(array $config): void
208+
{
209+
$this->validateTokenRefreshConfig($config);
210+
211+
$plugin = new TokenRefreshPlugin(
212+
userId: $config['user_id'],
213+
storage: $config['storage'],
214+
oauthProvider: $config['oauth_provider'],
215+
logger: $config['logger'] ?? new \Psr\Log\NullLogger(),
216+
maxRetryAttempts: $config['max_retry_attempts'] ?? 3
217+
);
218+
219+
$this->getClientBuilder()->addPlugin($plugin);
220+
}
221+
222+
/**
223+
* Enable automatic token refresh with OAuth provider auto-creation.
224+
*
225+
* This is a convenience method that creates the OAuth provider for you.
226+
* Use enableTokenRefresh() if you want more control over the provider configuration.
227+
*
228+
* @param array $config Configuration array with the following keys:
229+
* - user_id: User identifier for token storage
230+
* - storage: TokenStorageInterface implementation
231+
* - client_id: OAuth client ID
232+
* - client_secret: OAuth client secret
233+
* - redirect_uri: OAuth redirect URI
234+
* - environment: OAuth environment ('production', 'staging')
235+
* - logger: PSR-3 logger for token refresh events (optional)
236+
* - max_retry_attempts: Maximum retry attempts (optional, default: 3)
237+
* @return void
238+
* @throws \InvalidArgumentException If required configuration is missing
239+
*/
240+
public function enableTokenRefreshWithCredentials(array $config): void
241+
{
242+
$this->validateTokenRefreshCredentialsConfig($config);
243+
244+
// Create OAuth provider
245+
$oauthProvider = new Geocaching([
246+
'clientId' => $config['client_id'],
247+
'clientSecret' => $config['client_secret'],
248+
'redirectUri' => $config['redirect_uri'],
249+
'environment' => $config['environment'] ?? 'production',
250+
]);
251+
252+
// Use the main method with the created provider
253+
$this->enableTokenRefresh([
254+
'user_id' => $config['user_id'],
255+
'storage' => $config['storage'],
256+
'oauth_provider' => $oauthProvider,
257+
'logger' => $config['logger'] ?? new \Psr\Log\NullLogger(),
258+
'max_retry_attempts' => $config['max_retry_attempts'] ?? 3
259+
]);
260+
}
261+
262+
/**
263+
* Validate token refresh configuration.
264+
*
265+
* @param array $config
266+
* @return void
267+
* @throws \InvalidArgumentException
268+
*/
269+
private function validateTokenRefreshConfig(array $config): void
270+
{
271+
$required = ['user_id', 'storage', 'oauth_provider'];
272+
273+
foreach ($required as $key) {
274+
if (!isset($config[$key])) {
275+
throw new \InvalidArgumentException("Token refresh config missing required key: {$key}");
276+
}
277+
}
278+
279+
if (!$config['storage'] instanceof TokenStorageInterface) {
280+
throw new \InvalidArgumentException('Token storage must implement TokenStorageInterface');
281+
}
282+
283+
if (!$config['oauth_provider'] instanceof Geocaching) {
284+
throw new \InvalidArgumentException('OAuth provider must be an instance of League\OAuth2\Client\Provider\Geocaching');
285+
}
286+
287+
if (empty($config['user_id']) || !is_string($config['user_id'])) {
288+
throw new \InvalidArgumentException('user_id must be a non-empty string');
289+
}
290+
291+
if (isset($config['max_retry_attempts']) && (!is_int($config['max_retry_attempts']) || $config['max_retry_attempts'] < 1)) {
292+
throw new \InvalidArgumentException('max_retry_attempts must be a positive integer if provided');
293+
}
294+
}
295+
296+
/**
297+
* Validate token refresh configuration with credentials.
298+
*
299+
* @param array $config
300+
* @return void
301+
* @throws \InvalidArgumentException
302+
*/
303+
private function validateTokenRefreshCredentialsConfig(array $config): void
304+
{
305+
$required = ['user_id', 'storage', 'client_id', 'client_secret', 'redirect_uri'];
306+
307+
foreach ($required as $key) {
308+
if (!isset($config[$key])) {
309+
throw new \InvalidArgumentException("Token refresh config missing required key: {$key}");
310+
}
311+
}
312+
313+
if (!$config['storage'] instanceof TokenStorageInterface) {
314+
throw new \InvalidArgumentException('Token storage must implement TokenStorageInterface');
315+
}
316+
317+
if (empty($config['user_id']) || !is_string($config['user_id'])) {
318+
throw new \InvalidArgumentException('user_id must be a non-empty string');
319+
}
320+
321+
if (empty($config['client_id']) || !is_string($config['client_id'])) {
322+
throw new \InvalidArgumentException('client_id must be a non-empty string');
323+
}
324+
325+
if (empty($config['client_secret']) || !is_string($config['client_secret'])) {
326+
throw new \InvalidArgumentException('client_secret must be a non-empty string');
327+
}
328+
329+
if (empty($config['redirect_uri']) || !is_string($config['redirect_uri'])) {
330+
throw new \InvalidArgumentException('redirect_uri must be a non-empty string');
331+
}
332+
333+
if (isset($config['environment']) && !in_array($config['environment'], ['production', 'staging', 'dev'])) {
334+
throw new \InvalidArgumentException('environment must be one of: production, staging, dev');
335+
}
336+
337+
if (isset($config['max_retry_attempts']) && (!is_int($config['max_retry_attempts']) || $config['max_retry_attempts'] < 1)) {
338+
throw new \InvalidArgumentException('max_retry_attempts must be a positive integer if provided');
339+
}
340+
}
188341
}

0 commit comments

Comments
 (0)