diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c75626c..869349f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,16 +19,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- php-version: [ '8.1', '8.2', '8.3' ]
+ php-version: [ '8.2', '8.3', '8.4', '8.5' ]
steps:
- - uses: "actions/checkout@v3"
+ - uses: "actions/checkout@v6"
- uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php-version }}"
coverage: "none"
ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On"
tools: "composer:v2"
- - uses: "ramsey/composer-install@v2"
+ - uses: "ramsey/composer-install@v3"
- name: "Lint the PHP source code"
run: "./vendor/bin/parallel-lint src test"
@@ -36,14 +36,14 @@ jobs:
name: "Coding Standards"
runs-on: "ubuntu-latest"
steps:
- - uses: "actions/checkout@v3"
+ - uses: "actions/checkout@v6"
- uses: "shivammathur/setup-php@v2"
with:
php-version: "latest"
coverage: "none"
ini-values: "memory_limit=-1"
tools: "composer:v2"
- - uses: "ramsey/composer-install@v2"
+ - uses: "ramsey/composer-install@v3"
- name: "Check coding standards"
run: "./vendor/bin/phpcs src --standard=psr2 --exclude=Generic.Files.LineLength -sp --colors"
@@ -53,7 +53,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php-version: [ '8.1', '8.2', '8.3' ]
+ php-version: [ '8.2', '8.3', '8.4', '8.5' ]
steps:
- uses: "actions/checkout@v3"
- uses: "shivammathur/setup-php@v2"
@@ -68,4 +68,4 @@ jobs:
- name: "Run unit tests"
run: "./vendor/bin/phpunit --colors=always --coverage-clover build/logs/clover.xml --coverage-text"
- name: "Publish coverage report to Codecov"
- uses: "codecov/codecov-action@v3"
\ No newline at end of file
+ uses: "codecov/codecov-action@v5"
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..040ddc9
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [3.0.0] - 2025-12-xx
+### Added
+ - Token management primitives: `TokenSet`, `TokenStorageInterface` with locking, and serialization helpers.
+ - `TokenRefreshPlugin` to refresh access tokens on 401 responses and retry requests safely.
+ - Token refresh exceptions (`TokenRefreshException`, `RefreshTokenExpiredException`, `TokenStorageException`) for clearer error handling.
diff --git a/README.md b/README.md
index 8ee0857..5cff477 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,263 @@ Usage is the same as The League's OAuth client, using `\League\OAuth2\Client\Pro
### Authorization Code Flow
-Take a look at `demo/index.php`
+```php
+use League\OAuth2\Client\Provider\Geocaching;
+
+$provider = new Geocaching([
+ 'clientId' => 'your-client-id',
+ 'clientSecret' => 'your-client-secret',
+ 'redirectUri' => 'https://your-app.com/callback',
+ 'environment' => 'production', // 'dev', 'staging', 'production'
+]);
+
+// Get authorization URL
+$authUrl = $provider->getAuthorizationUrl(['scope' => '*']);
+$_SESSION['oauth2state'] = $provider->getState();
+
+// Redirect user to Geocaching
+header('Location: ' . $authUrl);
+exit;
+
+// In your callback handler
+if (!empty($_GET['code'])) {
+ $token = $provider->getAccessToken('authorization_code', [
+ 'code' => $_GET['code']
+ ]);
+
+ $user = $provider->getResourceOwner($token);
+ echo 'Hello ' . $user->getUsername() . '!';
+}
+```
+
+### Environments
+
+| Environment | Description | URLs |
+|------------|-------------|------|
+| `production`, `prod` | Official Geocaching.com | `geocaching.com`, `api.groundspeak.com` |
+| `staging`, `qa` | Staging environment | `staging.geocaching.com` |
+| `dev`, `development`, `docker`, `test` | Local development | `localhost:8000` |
+
+### Custom URLs for Development
+
+You can override environment URLs for your own development infrastructure:
+
+```php
+use League\OAuth2\Client\Provider\Geocaching;
+use League\OAuth2\Client\Provider\GeocachingConfig;
+
+// Method 1: Direct URL overrides
+$provider = new Geocaching([
+ 'clientId' => 'dev-client-id',
+ 'clientSecret' => 'dev-secret',
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://localhost:3000/callback',
+
+ // Custom URLs (override environment defaults)
+ 'domain' => 'https://my-geocaching.local',
+ 'apiDomain' => 'https://api.my-geocaching.local',
+ 'oAuthDomain' => 'https://oauth.my-geocaching.local',
+]);
+
+// Method 2: Using configuration helper
+$config = GeocachingConfig::create('staging', [
+ 'apiDomain' => 'https://my-internal-api.company.com'
+]);
+
+$provider = new Geocaching(array_merge([
+ 'clientId' => 'client-id',
+ 'clientSecret' => 'client-secret',
+ 'redirectUri' => 'https://app.company.com/callback',
+], $config));
+
+// Method 3: Factory helpers for common patterns
+use League\OAuth2\Client\Test\Factory\GeocachingTestFactory;
+
+$provider = GeocachingTestFactory::createForDocker(9000);
+// or
+$provider = GeocachingTestFactory::createForLocalDev('http://192.168.1.100:8080');
+```
+
+#### Factory Helpers for Common Patterns
+
+```php
+use League\OAuth2\Client\Test\Factory\GeocachingTestFactory;
+
+// Docker with custom port
+$provider = GeocachingTestFactory::createForDocker(9000);
+// Results in: http://localhost:9000, http://localhost:9000/api, http://localhost:9000/oauth
+
+// Local development with custom base URL
+$provider = GeocachingTestFactory::createForLocalDev('http://192.168.1.100:8080');
+// Results in: http://192.168.1.100:8080, http://192.168.1.100:8080/api/v1, http://192.168.1.100:8080/oauth
+
+// Completely custom URLs
+$provider = GeocachingTestFactory::createWithCustomUrls(
+ 'https://geocaching.mycompany.local',
+ 'https://api.geocaching.mycompany.local',
+ 'https://oauth.geocaching.mycompany.local'
+);
+```
+
+#### Advanced Configuration Examples
+
+**Docker Compose Setup:**
+```php
+$provider = new Geocaching([
+ 'clientId' => $_ENV['GEOCACHING_CLIENT_ID'],
+ 'clientSecret' => $_ENV['GEOCACHING_CLIENT_SECRET'],
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://localhost:3000/auth/callback',
+
+ 'domain' => 'http://geocaching-web:80',
+ 'apiDomain' => 'http://geocaching-api:8080/v1',
+ 'oAuthDomain' => 'http://geocaching-oauth:9090',
+]);
+```
+
+**Enterprise Infrastructure:**
+```php
+$config = GeocachingConfig::create('production', [
+ 'domain' => 'https://geocaching.internal.company.com',
+ 'apiDomain' => 'https://geocaching-api.internal.company.com/v2',
+ 'oAuthDomain' => 'https://sso.company.com/geocaching',
+]);
+
+$provider = new Geocaching(array_merge([
+ 'clientId' => $_ENV['COMPANY_GEOCACHING_CLIENT_ID'],
+ 'clientSecret' => $_ENV['COMPANY_GEOCACHING_CLIENT_SECRET'],
+ 'redirectUri' => 'https://myapp.company.com/oauth/callback',
+], $config));
+```
+
+**Multi-Environment Deployment:**
+```php
+$environment = $_ENV['APP_ENV'] ?? 'production';
+
+switch ($environment) {
+ case 'local':
+ $provider = GeocachingTestFactory::createForLocalDev($_ENV['DEV_BASE_URL']);
+ break;
+ case 'staging':
+ $config = GeocachingConfig::create('staging', [
+ 'apiDomain' => $_ENV['STAGING_API_URL'],
+ ]);
+ $provider = new Geocaching(array_merge($baseOptions, $config));
+ break;
+ case 'production':
+ default:
+ $provider = new Geocaching(array_merge($baseOptions, [
+ 'environment' => 'production'
+ ]));
+ break;
+}
+```
+
+Take a look at `demo/index.php` for complete examples.
+
+### Resource Owner (User Data)
+
+After obtaining an access token, you can retrieve user information:
+
+```php
+$user = $provider->getResourceOwner($token);
+
+// Basic information
+echo $user->getReferenceCode(); // 'PR1ABC2'
+echo $user->getUsername(); // 'MyUsername'
+echo $user->getFindCount(); // 150
+echo $user->getHideCount(); // 5
+echo $user->getFavoritePoints(); // 25
+
+// Profile information
+echo $user->getMembershipLevelId(); // 3 (Premium membership)
+echo $user->getJoinedDate(); // '2020-01-15T10:30:00.123'
+echo $user->getAvatarUrl(); // URL to user's avatar image
+echo $user->getProfileUrl(); // URL to user's public profile
+echo $user->getProfileText(); // User's profile description
+
+// Additional data
+$coordinates = $user->getHomeCoordinates(); // Array with lat/lon
+$geocacheLimits = $user->getGeocacheLimits(); // API usage limits
+$friendSharing = $user->getOptedInFriendSharing(); // Privacy setting
+```
+
+#### Custom Resource Owner Fields
+
+By default, the provider requests a standard set of user fields. You can customize which fields to retrieve:
+
+```php
+$provider->setResourceOwnerFields([
+ 'referenceCode',
+ 'username',
+ 'findCount',
+ 'hideCount',
+ 'favoritePoints',
+ 'membershipLevelId',
+ 'joinedDateUtc',
+ 'avatarUrl',
+ 'profileText'
+ // Add any other fields supported by the Geocaching API
+]);
+
+$user = $provider->getResourceOwner($token);
+// Now only the specified fields will be requested from the API
+```
+
+### Token Management & Refresh
+
+This package ships token lifecycle utilities you can plug into any PSR-18 client:
+
+- `TokenSet`: lightweight DTO for access/refresh tokens with expiry helpers.
+- `TokenStorageInterface`: implement to persist tokens (DB, cache, file) with locking.
+- `TokenRefreshPlugin`: HTTPlug/PSR plugin that refreshes tokens on `401` and retries the original request.
+- Exceptions for refresh/storage errors: `TokenRefreshException`, `RefreshTokenExpiredException`, `TokenStorageException`.
+
+Basic wiring:
+
+```php
+use Http\Client\Common\PluginClientFactory;
+use League\OAuth2\Client\Plugin\TokenRefreshPlugin;
+use League\OAuth2\Client\Provider\Geocaching;
+use League\OAuth2\Client\Token\TokenStorageInterface;
+use League\OAuth2\Client\Token\TokenSet;
+use Nyholm\Psr7\Request;
+use Psr\Log\NullLogger;
+
+$provider = new Geocaching([
+ 'clientId' => 'client_id',
+ 'clientSecret' => 'client_secret',
+ 'redirectUri' => 'https://your-app.test/callback',
+ 'environment' => 'production', // or staging/dev
+]);
+
+$storage = new class implements TokenStorageInterface {
+ private ?TokenSet $tokens = null;
+ public function getTokens(string $referenceCode): ?TokenSet { return $this->tokens; }
+ public function saveTokens(string $referenceCode, TokenSet $tokens): void { $this->tokens = $tokens; }
+ public function lockUser(string $referenceCode, int $timeoutSeconds = 30): bool { return true; }
+ public function unlockUser(string $referenceCode): void {}
+ public function isUserLocked(string $referenceCode): bool { return false; }
+};
+
+$refreshPlugin = new TokenRefreshPlugin(
+ referenceCode: 'PR12345',
+ storage: $storage,
+ oauthProvider: $provider,
+ logger: new NullLogger(),
+ maxRetryAttempts: 3
+);
+
+$httpClient = (new PluginClientFactory())->createClient(
+ \Http\Discovery\Psr18ClientDiscovery::find(),
+ [$refreshPlugin]
+);
+
+$request = new Request('GET', $provider->apiDomain . '/v1/users/PR12345');
+$response = $httpClient->sendRequest($request);
+```
+
+See `demo/index.php` for a full flow including PKCE, token storage, and a sample API call with automatic refresh. In production, replace the in-memory storage with a durable implementation.
## Testing
diff --git a/composer.json b/composer.json
index 1543b24..c5ca20d 100644
--- a/composer.json
+++ b/composer.json
@@ -18,17 +18,20 @@
"pkce"
],
"require": {
- "php": "^8.1",
- "league/oauth2-client": "^2.7"
+ "php": ">=8.2",
+ "league/oauth2-client": "^2.9",
+ "php-http/client-common": "^2.7",
+ "php-http/promise": "^1.1",
+ "psr/log": "^3.0"
},
"require-dev": {
- "mockery/mockery": "~1.4",
"php-parallel-lint/php-parallel-lint": "~1.3",
- "phpstan/phpstan": "^1.9",
- "phpunit/php-code-coverage": "^10.0",
- "phpunit/phpunit": "^10.0",
- "rector/rector": "^0.17",
- "squizlabs/php_codesniffer": "~3.5"
+ "phpstan/phpstan": "^2.0",
+ "phpunit/php-code-coverage": "^11.0 || ^12.0",
+ "phpunit/phpunit": "^11.0 || ^12.0",
+ "rector/rector": "^2.0",
+ "squizlabs/php_codesniffer": "~4.0",
+ "nyholm/psr7": "^1.8"
},
"autoload": {
"psr-4": {
diff --git a/demo/index.php b/demo/index.php
index 0a61c36..f111041 100644
--- a/demo/index.php
+++ b/demo/index.php
@@ -4,9 +4,16 @@
use League\OAuth2\Client\Provider\Geocaching;
use League\OAuth2\Client\Provider\Exception\GeocachingIdentityProviderException;
+use League\OAuth2\Client\Plugin\TokenRefreshPlugin;
+use League\OAuth2\Client\Token\TokenSet;
+use League\OAuth2\Client\Token\TokenStorageInterface;
+use Http\Client\Common\PluginClientFactory;
+use Http\Discovery\Psr18ClientDiscovery;
+use Psr\Http\Client\ClientInterface;
+use Nyholm\Psr7\Request;
-define('GEOCACHING_CLIENT_ID', '');
-define('GEOCACHING_CLIENT_SECRET', '');
+define('GEOCACHING_CLIENT_ID', 'A2D5114A-0B13-4891-9736-A3D4D93030E5');
+define('GEOCACHING_CLIENT_SECRET', '3C0A483E-D7C9-48F7-A442-793F5FA5D234');
define('GEOCACHING_ENVIRONMENT', 'staging'); // staging, production
define('GEOCACHING_CALLBACK', 'http://localhost:8000');
@@ -40,8 +47,13 @@
$provider->setPkceCode($_SESSION['oauth2pkceCode']);
$token = $provider->getAccessToken('authorization_code', [
- 'code' => $_GET['code'],
+ 'code' => $_GET['code'],
]);
+ $_SESSION['tokens'] = [
+ 'access_token' => $token->getToken(),
+ 'refresh_token' => $token->getRefreshToken(),
+ 'expires_in' => $token->getExpires() ? ($token->getExpires() - time()) : 3600,
+ ];
} catch (GeocachingIdentityProviderException $e) {
exit($e->getMessage());
}
@@ -66,6 +78,8 @@
echo "
";
print_r($user->toArray());
echo "";
+
+ $_SESSION['referenceCode'] = $user->getReferenceCode();
} catch (Exception $e) {
// Failed to get user details
echo "Error: " . $e->getMessage()."
";
@@ -78,4 +92,47 @@
// Use this to interact with an API on the users behalf
// echo "Token: " . $token->getToken();
+
+ // Example: prepare a PSR-18 client with TokenRefreshPlugin to auto-refresh on 401
+ if (isset($_SESSION['tokens']) && !empty($_SESSION['referenceCode'])) {
+ $storage = new class implements TokenStorageInterface {
+ public function getTokens(string $referenceCode): ?TokenSet
+ {
+ return isset($_SESSION['tokens'])
+ ? TokenSet::fromOAuthResponse($_SESSION['tokens'], $_SESSION['tokens']['refresh_token'] ?? null)
+ : null;
+ }
+
+ public function saveTokens(string $referenceCode, TokenSet $tokens): void
+ {
+ $_SESSION['tokens'] = $tokens->toArray();
+ }
+
+ public function lockUser(string $referenceCode, int $timeoutSeconds = 30): bool
+ {
+ return true;
+ }
+
+ public function unlockUser(string $referenceCode): void
+ {
+ }
+
+ public function isUserLocked(string $referenceCode): bool
+ {
+ return false;
+ }
+ };
+
+ $refreshPlugin = new TokenRefreshPlugin(
+ referenceCode: $_SESSION['referenceCode'],
+ storage: $storage,
+ oauthProvider: $provider
+ );
+
+ /** @var ClientInterface $httpClient */
+ $httpClient = (new PluginClientFactory())->createClient(
+ Psr18ClientDiscovery::find(),
+ [$refreshPlugin]
+ );
+ }
}
diff --git a/phpunit.xml b/phpunit.xml
index 6058783..1f0769e 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -10,6 +10,9 @@
./test/src/Provider
+ ./test/src/Plugin
+ ./test/src/Token
+ ./test/src/Exception
diff --git a/src/Exception/RefreshTokenExpiredException.php b/src/Exception/RefreshTokenExpiredException.php
new file mode 100644
index 0000000..2b89c6c
--- /dev/null
+++ b/src/Exception/RefreshTokenExpiredException.php
@@ -0,0 +1,9 @@
+responseData;
+ }
+
+ public static function fromOAuthResponse(\Throwable $previous, ?array $responseData = null): self
+ {
+ return new self(
+ 'OAuth provider error: ' . $previous->getMessage(),
+ $previous->getCode(),
+ $previous,
+ $responseData
+ );
+ }
+}
diff --git a/src/Exception/TokenStorageException.php b/src/Exception/TokenStorageException.php
new file mode 100644
index 0000000..11cbc07
--- /dev/null
+++ b/src/Exception/TokenStorageException.php
@@ -0,0 +1,9 @@
+logger = $logger ?? new NullLogger();
+ }
+
+ public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
+ {
+ return $next($request)->then(
+ function (ResponseInterface $response) use ($request, $first) {
+ // If we get a 401, try to refresh the token and retry
+ if ($response->getStatusCode() === 401) {
+ $this->logger->warning('[GEOCACHING] Received 401, attempting token refresh', [
+ 'reference_code' => $this->referenceCode,
+ 'request_uri' => (string) $request->getUri(),
+ ]);
+
+ // Unwrap the refreshed call to return a ResponseInterface (avoid Promise nesting)
+ return $this->refreshTokenAndRetry($request, $first)->wait();
+ }
+
+ return $response;
+ },
+ function (\Throwable $exception) {
+ // Re-throw any other exceptions
+ throw $exception;
+ }
+ );
+ }
+
+ /**
+ * Refresh the token and retry the original request.
+ */
+ private function refreshTokenAndRetry(RequestInterface $request, callable $first): Promise
+ {
+ try {
+ $newTokens = $this->refreshAccessToken(forceRefresh: true);
+ $newRequest = $this->updateRequestWithNewToken($request, $newTokens);
+
+ $this->logger->notice('[GEOCACHING] Token refreshed successfully, retrying request', [
+ 'reference_code' => $this->referenceCode,
+ 'expires_at' => $newTokens->expiresAt->format('Y-m-d H:i:s'),
+ ]);
+
+ return $first($newRequest)->then(
+ function (ResponseInterface $response) {
+ return $response;
+ },
+ function (\Throwable $exception) {
+ throw $exception;
+ }
+ );
+ } catch (RefreshTokenExpiredException $e) {
+ $this->logger->error('[GEOCACHING] Refresh token expired, user must re-authenticate', [
+ 'reference_code' => $this->referenceCode,
+ 'error' => $e->getMessage(),
+ ]);
+ throw $e;
+ } catch (TokenRefreshException $e) {
+ $this->logger->error('[GEOCACHING] Failed to refresh token', [
+ 'reference_code' => $this->referenceCode,
+ 'error' => $e->getMessage(),
+ 'response_data' => $e->getResponseData(),
+ ]);
+ throw $e;
+ }
+ }
+
+ /**
+ * Refresh the access token with concurrency protection.
+ */
+ private function refreshAccessToken(bool $forceRefresh = false): TokenSet
+ {
+ // Try to acquire lock, with retries for concurrent requests
+ for ($attempt = 0; $attempt < $this->maxRetryAttempts; $attempt++) {
+ if ($this->storage->lockUser($this->referenceCode)) {
+ try {
+ return $this->performTokenRefresh($forceRefresh);
+ } finally {
+ $this->storage->unlockUser($this->referenceCode);
+ }
+ }
+
+ // Another process is refreshing, wait and check if they succeeded
+ if ($attempt < $this->maxRetryAttempts - 1) {
+ usleep($this->calculateBackoff($attempt));
+
+ // Check if the other process already refreshed the token
+ $tokens = $this->storage->getTokens($this->referenceCode);
+ if ($tokens && !$tokens->isExpired()) {
+ $this->logger->debug('[GEOCACHING] Token was refreshed by another process', [
+ 'reference_code' => $this->referenceCode,
+ ]);
+ return $tokens;
+ }
+ }
+ }
+
+ throw new TokenStorageException("Could not acquire lock for user {$this->referenceCode} after {$this->maxRetryAttempts} attempts");
+ }
+
+ /**
+ * Perform the actual token refresh (must be called within lock).
+ */
+ private function performTokenRefresh(bool $forceRefresh = false): TokenSet
+ {
+ // Get current tokens
+ $currentTokens = $this->storage->getTokens($this->referenceCode);
+ if (!$currentTokens) {
+ throw new TokenStorageException("No tokens found for user {$this->referenceCode}");
+ }
+
+ // If we were called because of a 401, force a refresh even if the token
+ // is not expired. Some APIs may revoke tokens early.
+ if (!$forceRefresh && !$currentTokens->isExpired()) {
+ $this->logger->debug('[GEOCACHING] Token is no longer expired, using existing token', [
+ 'reference_code' => $this->referenceCode,
+ ]);
+ return $currentTokens;
+ }
+
+ // Call OAuth refresh endpoint
+ $oauthResponse = $this->callOAuthRefreshEndpoint($currentTokens->refreshToken);
+
+ // Create new token set
+ $newTokens = TokenSet::fromOAuthResponse($oauthResponse, $currentTokens->refreshToken);
+
+ // Save to storage
+ $this->storage->saveTokens($this->referenceCode, $newTokens);
+
+ return $newTokens;
+ }
+
+ /**
+ * Call the OAuth refresh endpoint using the Geocaching provider.
+ */
+ private function callOAuthRefreshEndpoint(string $refreshToken): array
+ {
+ try {
+ $this->logger->debug('[GEOCACHING] Refreshing token using OAuth provider', [
+ 'reference_code' => $this->referenceCode,
+ 'provider_class' => $this->oauthProvider::class,
+ ]);
+
+ // Use the League OAuth2 provider to refresh the token
+ $accessToken = $this->oauthProvider->getAccessToken('refresh_token', [
+ 'refresh_token' => $refreshToken,
+ ]);
+
+ $this->logger->debug('[GEOCACHING] Token refresh successful', [
+ 'reference_code' => $this->referenceCode,
+ 'expires' => $accessToken->getExpires(),
+ 'has_refresh_token' => !empty($accessToken->getRefreshToken()),
+ ]);
+
+ // Convert AccessToken to our expected array format
+ return $this->convertAccessTokenToArray($accessToken, $refreshToken);
+ } catch (GeocachingIdentityProviderException $e) {
+ $responseBody = $e->getResponseBody();
+
+ $this->logger->error('[GEOCACHING] OAuth provider exception during refresh', [
+ 'reference_code' => $this->referenceCode,
+ 'error' => $e->getMessage(),
+ 'response_body' => $responseBody,
+ ]);
+
+ // Map provider exceptions to our exceptions
+ $errorData = json_decode((string) $responseBody, true) ?? [];
+
+ // Check for expired refresh token errors
+ if (isset($errorData['error'])) {
+ $expiredErrors = ['invalid_grant', 'invalid_request', 'unauthorized_client'];
+ if (in_array($errorData['error'], $expiredErrors, true)) {
+ throw new RefreshTokenExpiredException(
+ $errorData['error_description'] ?? 'Refresh token is invalid or expired'
+ );
+ }
+ }
+
+ throw new TokenRefreshException(
+ 'OAuth provider error: ' . $e->getMessage(),
+ $e->getCode(),
+ $e,
+ $errorData
+ );
+ } catch (\Exception $e) {
+ $this->logger->error('[GEOCACHING] Unexpected error during token refresh', [
+ 'reference_code' => $this->referenceCode,
+ 'error' => $e->getMessage(),
+ 'exception_class' => $e::class,
+ ]);
+
+ throw new TokenRefreshException(
+ 'Unexpected error during token refresh: ' . $e->getMessage(),
+ $e->getCode(),
+ $e
+ );
+ }
+ }
+
+ /**
+ * Convert League OAuth2 AccessToken to our expected array format.
+ */
+ private function convertAccessTokenToArray(AccessTokenInterface $accessToken, string $originalRefreshToken): array
+ {
+ return [
+ 'access_token' => $accessToken->getToken(),
+ 'refresh_token' => $accessToken->getRefreshToken() ?: $originalRefreshToken,
+ 'expires_in' => $accessToken->getExpires() ? ($accessToken->getExpires() - time()) : 3600,
+ 'token_type' => 'Bearer',
+ 'scope' => null,
+ ];
+ }
+
+ /**
+ * Update request with new access token.
+ */
+ private function updateRequestWithNewToken(RequestInterface $request, TokenSet $tokens): RequestInterface
+ {
+ return $request->withHeader('Authorization', $tokens->getAuthorizationHeader());
+ }
+
+ /**
+ * Calculate progressive backoff delay in microseconds.
+ */
+ private function calculateBackoff(int $attempt): int
+ {
+ $backoffMs = min(
+ self::BASE_BACKOFF_MS * pow(self::BACKOFF_MULTIPLIER, $attempt),
+ self::MAX_BACKOFF_MS
+ );
+
+ return (int)($backoffMs * 1000); // Convert to microseconds
+ }
+}
diff --git a/src/Provider/Exception/GeocachingIdentityProviderException.php b/src/Provider/Exception/GeocachingIdentityProviderException.php
index 2e2afe8..c53b66d 100644
--- a/src/Provider/Exception/GeocachingIdentityProviderException.php
+++ b/src/Provider/Exception/GeocachingIdentityProviderException.php
@@ -9,23 +9,21 @@ final class GeocachingIdentityProviderException extends IdentityProviderExceptio
/**
* Creates client exception from response.
*/
- public static function clientException(ResponseInterface $response, array $data): IdentityProviderException
+ public static function clientException(ResponseInterface $response, $data): IdentityProviderException
{
- return static::fromResponse(
- $response,
- $data['message'] ?? $response->getReasonPhrase()
- );
+ $message = is_string($data) ? $data : ($data['message'] ?? $response->getReasonPhrase());
+
+ return static::fromResponse($response, $message);
}
/**
* Creates oauth exception from response.
*/
- public static function oauthException(ResponseInterface $response, array $data): IdentityProviderException
+ public static function oauthException(ResponseInterface $response, $data): IdentityProviderException
{
- return static::fromResponse(
- $response,
- $data['error'] ?? $response->getReasonPhrase()
- );
+ $message = is_string($data) ? $data : ($data['error'] ?? $response->getReasonPhrase());
+
+ return static::fromResponse($response, $message);
}
/**
diff --git a/src/Provider/Geocaching.php b/src/Provider/Geocaching.php
index ed5bec9..04a4beb 100644
--- a/src/Provider/Geocaching.php
+++ b/src/Provider/Geocaching.php
@@ -32,68 +32,65 @@ class Geocaching extends AbstractProvider
public string $oAuthDomain;
- public $clientId;
+ protected $clientId;
- public $clientSecret;
+ protected $clientSecret;
- public $redirectUri;
+ protected $redirectUri;
- public string $scope = '*';
+ public string $scope = GeocachingConfig::DEFAULT_SCOPE;
- public string $pkceMethod = 'S256';
+ public string $pkceMethod = GeocachingConfig::DEFAULT_PKCE_METHOD;
- private string $responseResourceOwnerId = 'referenceCode';
+ private string $responseResourceOwnerId = GeocachingConfig::RESPONSE_RESOURCE_OWNER_ID;
- private array $resourceOwnerFieldsDefault = [
- 'referenceCode',
- 'findCount',
- 'hideCount',
- 'favoritePoints',
- 'username',
- 'membershipLevelId',
- 'joinedDateUtc',
- ];
+ private array $resourceOwnerFieldsDefault = GeocachingConfig::DEFAULT_RESOURCE_OWNER_FIELDS;
private array $resourceOwnerFields;
/**
- * Constructs an OAuth 2.0 service provider.oAuthDomain
+ * Constructs an OAuth 2.0 service provider for Geocaching.
*
* @param array $options An array of options to set on this provider.
- * Options include `clientId`, `clientSecret`, `redirectUri`, and `state`.
- * Individual providers may introduce more options, as needed.
+ * Standard options: `clientId`, `clientSecret`, `redirectUri`, `environment`.
+ * Custom URL options: `domain`, `apiDomain`, `oAuthDomain` (override environment defaults).
* @param array $collaborators An array of collaborators that may be used to
* override this provider's default behavior. Collaborators include
* `grantFactory`, `requestFactory`, and `httpClient`.
- * Individual providers may introduce more collaborators, as needed.
+ *
+ * @example
+ * ```php
+ * // Standard usage with predefined environment
+ * $provider = new Geocaching([
+ * 'clientId' => 'your-client-id',
+ * 'clientSecret' => 'your-client-secret',
+ * 'environment' => 'staging',
+ * 'redirectUri' => 'https://app.example.com/callback'
+ * ]);
+ *
+ * // Custom URLs for development
+ * $provider = new Geocaching([
+ * 'clientId' => 'dev-client-id',
+ * 'clientSecret' => 'dev-client-secret',
+ * 'environment' => 'dev',
+ * 'redirectUri' => 'http://localhost:3000/callback',
+ * 'domain' => 'https://my-geocaching.local',
+ * 'apiDomain' => 'https://api.my-geocaching.local'
+ * ]);
+ * ```
*/
public function __construct(array $options = [], array $collaborators = [])
{
$this->assertRequiredOptions($options);
-
parent::__construct($options, $collaborators);
- switch ($this->environment) {
- case 'dev':
- case 'development':
- case 'docker':
- $this->domain = self::DEV_DOMAIN;
- $this->apiDomain = self::DEV_DOMAIN;
- $this->oAuthDomain = self::DEV_DOMAIN;
- break;
- case 'staging':
- case 'qa':
- $this->domain = self::STAGING_DOMAIN;
- $this->apiDomain = self::STAGING_API_DOMAIN;
- $this->oAuthDomain = self::STAGING_OAUTH_DOMAIN;
- break;
- case 'production':
- case 'prod':
- default:
- $this->domain = self::PRODUCTION_DOMAIN;
- $this->apiDomain = self::PRODUCTION_API_DOMAIN;
- $this->oAuthDomain = self::PRODUCTION_OAUTH_DOMAIN;
- }
+ // Get environment defaults
+ $config = GeocachingConfig::getEnvironmentConfig($this->environment);
+
+ // Allow custom URL overrides, fallback to environment defaults
+ $this->domain = $options['domain'] ?? $config['domain'];
+ $this->apiDomain = $options['apiDomain'] ?? $config['apiDomain'];
+ $this->oAuthDomain = $options['oAuthDomain'] ?? $config['oAuthDomain'];
}
/**
@@ -108,6 +105,9 @@ protected function getConfigurableOptions(): array
'clientSecret',
'redirectUri',
'environment',
+ 'domain', // Allow custom domain override
+ 'apiDomain', // Allow custom API domain override
+ 'oAuthDomain', // Allow custom OAuth domain override
]);
}
@@ -116,7 +116,7 @@ protected function getConfigurableOptions(): array
*
* @return array
*/
- protected function getRequiredOptions()
+ protected function getRequiredOptions(): array
{
return [
'clientId',
@@ -126,7 +126,7 @@ protected function getRequiredOptions()
];
}
- private function assertRequiredOptions(array $options)
+ private function assertRequiredOptions(array $options): void
{
$missing = array_diff_key(array_flip($this->getRequiredOptions()), $options);
diff --git a/src/Provider/GeocachingConfig.php b/src/Provider/GeocachingConfig.php
new file mode 100644
index 0000000..fc85e22
--- /dev/null
+++ b/src/Provider/GeocachingConfig.php
@@ -0,0 +1,144 @@
+ [
+ 'domain' => 'http://localhost:8000',
+ 'apiDomain' => 'http://localhost:8000',
+ 'oAuthDomain' => 'http://localhost:8000',
+ ],
+ 'development' => [
+ 'domain' => 'http://localhost:8000',
+ 'apiDomain' => 'http://localhost:8000',
+ 'oAuthDomain' => 'http://localhost:8000',
+ ],
+ 'docker' => [
+ 'domain' => 'http://localhost:8000',
+ 'apiDomain' => 'http://localhost:8000',
+ 'oAuthDomain' => 'http://localhost:8000',
+ ],
+ 'staging' => [
+ 'domain' => 'https://staging.geocaching.com',
+ 'apiDomain' => 'https://staging.api.groundspeak.com',
+ 'oAuthDomain' => 'https://oauth-staging.geocaching.com',
+ ],
+ 'qa' => [
+ 'domain' => 'https://staging.geocaching.com',
+ 'apiDomain' => 'https://staging.api.groundspeak.com',
+ 'oAuthDomain' => 'https://oauth-staging.geocaching.com',
+ ],
+ 'production' => [
+ 'domain' => 'https://www.geocaching.com',
+ 'apiDomain' => 'https://api.groundspeak.com',
+ 'oAuthDomain' => 'https://oauth.geocaching.com',
+ ],
+ 'prod' => [
+ 'domain' => 'https://www.geocaching.com',
+ 'apiDomain' => 'https://api.groundspeak.com',
+ 'oAuthDomain' => 'https://oauth.geocaching.com',
+ ],
+ 'test' => [
+ 'domain' => 'http://localhost:8000',
+ 'apiDomain' => 'http://localhost:8000',
+ 'oAuthDomain' => 'http://localhost:8000',
+ ],
+ ];
+
+ public const DEFAULT_RESOURCE_OWNER_FIELDS = [
+ 'referenceCode',
+ 'findCount',
+ 'hideCount',
+ 'favoritePoints',
+ 'username',
+ 'membershipLevelId',
+ 'joinedDateUtc',
+ ];
+
+ public const DEFAULT_SCOPE = '*';
+ public const DEFAULT_PKCE_METHOD = 'S256';
+ public const RESPONSE_RESOURCE_OWNER_ID = 'referenceCode';
+
+ /**
+ * Get configuration for a specific environment.
+ *
+ * @param string $environment The environment name
+ * @return array The configuration array for the environment
+ * @throws \InvalidArgumentException If the environment is not valid
+ */
+ public static function getEnvironmentConfig(string $environment): array
+ {
+ if (!self::isValidEnvironment($environment)) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'Invalid environment "%s". Valid options: %s',
+ $environment,
+ implode(', ', self::VALID_ENVIRONMENTS)
+ )
+ );
+ }
+
+ return self::ENVIRONMENTS[$environment];
+ }
+
+ /**
+ * Check if an environment is valid.
+ */
+ public static function isValidEnvironment(string $environment): bool
+ {
+ return in_array($environment, self::VALID_ENVIRONMENTS, true);
+ }
+
+ /**
+ * Get all valid environment names.
+ */
+ public static function getValidEnvironments(): array
+ {
+ return self::VALID_ENVIRONMENTS;
+ }
+
+ /**
+ * Create a custom configuration with URL overrides.
+ *
+ * Creates a configuration array based on a predefined environment with optional URL overrides.
+ * Empty strings and null values in overrides are automatically filtered out.
+ *
+ * @param string $baseEnvironment Base environment to start from ('dev', 'staging', 'production', etc.)
+ * @param array $overrides Custom URLs to override (domain, apiDomain, oAuthDomain)
+ * @return array The merged configuration with base environment + overrides
+ *
+ * @example
+ * ```php
+ * $config = GeocachingConfig::create('staging', [
+ * 'apiDomain' => 'https://my-api.example.com',
+ * ]);
+ * // Result: staging URLs + custom API domain
+ * ```
+ */
+ public static function create(string $baseEnvironment, array $overrides = []): array
+ {
+ $baseConfig = self::getEnvironmentConfig($baseEnvironment);
+
+ return array_merge($baseConfig, array_filter($overrides, function ($value) {
+ return $value !== null && $value !== '';
+ }));
+ }
+}
diff --git a/src/Token/TokenSet.php b/src/Token/TokenSet.php
new file mode 100644
index 0000000..fede278
--- /dev/null
+++ b/src/Token/TokenSet.php
@@ -0,0 +1,128 @@
+expiresAt->modify("-{$bufferSeconds} seconds");
+
+ return $now >= $expiryWithBuffer;
+ }
+
+ /**
+ * Get seconds until expiration.
+ *
+ * @return int Seconds until expiration (negative if already expired)
+ */
+ public function getSecondsUntilExpiry(): int
+ {
+ $now = new DateTimeImmutable();
+ return $this->expiresAt->getTimestamp() - $now->getTimestamp();
+ }
+
+ /**
+ * Get the Authorization header value.
+ *
+ * @return string The complete Authorization header value
+ */
+ public function getAuthorizationHeader(): string
+ {
+ return $this->tokenType . ' ' . $this->accessToken;
+ }
+
+ /**
+ * Convert to array for storage.
+ */
+ public function toArray(): array
+ {
+ return [
+ 'access_token' => $this->accessToken,
+ 'refresh_token' => $this->refreshToken,
+ 'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'),
+ 'token_type' => $this->tokenType,
+ 'scopes' => $this->scopes,
+ ];
+ }
+
+ /**
+ * Create from stored array.
+ */
+ public static function fromArray(array $data): self
+ {
+ $expiresAt = new DateTimeImmutable($data['expires_at']);
+
+ return new self(
+ $data['access_token'],
+ $data['refresh_token'],
+ $expiresAt,
+ $data['token_type'] ?? 'Bearer',
+ $data['scopes'] ?? null
+ );
+ }
+}
diff --git a/src/Token/TokenStorageInterface.php b/src/Token/TokenStorageInterface.php
new file mode 100644
index 0000000..132d41d
--- /dev/null
+++ b/src/Token/TokenStorageInterface.php
@@ -0,0 +1,54 @@
+ 'invalid_token']);
+
+ self::assertSame(['error' => 'invalid_token'], $exception->getResponseData());
+ }
+
+ public function testFactoryWrapsPrevious(): void
+ {
+ $previous = new \RuntimeException('oauth down', 500);
+
+ $exception = TokenRefreshException::fromOAuthResponse($previous, ['foo' => 'bar']);
+
+ self::assertSame('OAuth provider error: oauth down', $exception->getMessage());
+ self::assertSame(500, $exception->getCode());
+ self::assertSame($previous, $exception->getPrevious());
+ self::assertSame(['foo' => 'bar'], $exception->getResponseData());
+ }
+
+ public function testRefreshTokenExpiredExtendsTokenRefreshException(): void
+ {
+ $expired = new RefreshTokenExpiredException('expired');
+
+ self::assertInstanceOf(TokenRefreshException::class, $expired);
+ }
+}
diff --git a/test/src/Factory/GeocachingTestFactory.php b/test/src/Factory/GeocachingTestFactory.php
new file mode 100644
index 0000000..4c8fd7d
--- /dev/null
+++ b/test/src/Factory/GeocachingTestFactory.php
@@ -0,0 +1,131 @@
+ 'dev-client-id',
+ 'clientSecret' => 'dev-client-secret',
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://localhost:3000/callback',
+ 'scope' => '*',
+ ];
+
+ return new Geocaching(array_merge($defaults, $overrides));
+ }
+
+ /**
+ * Create a staging environment provider.
+ */
+ public static function createStaging(array $overrides = []): Geocaching
+ {
+ $defaults = [
+ 'clientId' => 'staging-client-id',
+ 'clientSecret' => 'staging-client-secret',
+ 'environment' => 'staging',
+ 'redirectUri' => 'https://staging.example.com/callback',
+ 'scope' => '*',
+ ];
+
+ return new Geocaching(array_merge($defaults, $overrides));
+ }
+
+ /**
+ * Create a production environment provider.
+ */
+ public static function createProduction(array $overrides = []): Geocaching
+ {
+ $defaults = [
+ 'clientId' => 'prod-client-id',
+ 'clientSecret' => 'prod-client-secret',
+ 'environment' => 'production',
+ 'redirectUri' => 'https://example.com/callback',
+ 'scope' => '*',
+ ];
+
+ return new Geocaching(array_merge($defaults, $overrides));
+ }
+
+ /**
+ * Create a provider with custom resource owner fields.
+ */
+ public static function createWithCustomFields(array $resourceOwnerFields): Geocaching
+ {
+ $provider = self::createDev();
+ $provider->setResourceOwnerFields($resourceOwnerFields);
+
+ return $provider;
+ }
+
+ /**
+ * Create a provider with minimal configuration for testing.
+ */
+ public static function createMinimal(): Geocaching
+ {
+ return new Geocaching([
+ 'clientId' => 'test-id',
+ 'clientSecret' => 'test-secret',
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://test.local/callback',
+ ]);
+ }
+
+ /**
+ * Create a provider with completely custom URLs.
+ */
+ public static function createWithCustomUrls(
+ string $domain,
+ string $apiDomain,
+ string $oAuthDomain,
+ array $overrides = []
+ ): Geocaching {
+ $defaults = [
+ 'clientId' => 'custom-client-id',
+ 'clientSecret' => 'custom-client-secret',
+ 'environment' => 'dev', // Base environment (required)
+ 'redirectUri' => 'http://localhost:3000/callback',
+ 'domain' => $domain,
+ 'apiDomain' => $apiDomain,
+ 'oAuthDomain' => $oAuthDomain,
+ ];
+
+ return new Geocaching(array_merge($defaults, $overrides));
+ }
+
+ /**
+ * Create a provider with Docker/local development setup.
+ */
+ public static function createForDocker(int $port = 8080): Geocaching
+ {
+ return self::createWithCustomUrls(
+ "http://localhost:{$port}",
+ "http://localhost:{$port}/api",
+ "http://localhost:{$port}/oauth"
+ );
+ }
+
+ /**
+ * Create a provider for local development with custom API endpoints.
+ */
+ public static function createForLocalDev(string $baseUrl = 'http://localhost:3000'): Geocaching
+ {
+ return self::createWithCustomUrls(
+ $baseUrl,
+ "{$baseUrl}/api/v1",
+ "{$baseUrl}/oauth"
+ );
+ }
+}
\ No newline at end of file
diff --git a/test/src/Factory/TokenSetTestFactory.php b/test/src/Factory/TokenSetTestFactory.php
new file mode 100644
index 0000000..7c0b301
--- /dev/null
+++ b/test/src/Factory/TokenSetTestFactory.php
@@ -0,0 +1,99 @@
+ 'oauth-access-token',
+ 'expires_in' => 3600,
+ 'token_type' => 'Bearer',
+ 'scope' => 'read write'
+ ];
+
+ return TokenSet::fromOAuthResponse(
+ $oauthResponse ?? $defaultResponse,
+ $refreshToken
+ );
+ }
+}
\ No newline at end of file
diff --git a/test/src/Plugin/TokenRefreshPluginTest.php b/test/src/Plugin/TokenRefreshPluginTest.php
new file mode 100644
index 0000000..a98d9b1
--- /dev/null
+++ b/test/src/Plugin/TokenRefreshPluginTest.php
@@ -0,0 +1,268 @@
+createMock(TokenStorageInterface::class);
+ $storage->expects($this->once())->method('lockUser')->with($reference)->willReturn(true);
+ $storage->expects($this->once())->method('unlockUser')->with($reference);
+ $storage->expects($this->once())->method('getTokens')->with($reference)->willReturn($initialTokens);
+ $storage->expects($this->once())->method('saveTokens')->with(
+ $reference,
+ $this->callback(fn (TokenSet $tokens) => $tokens->accessToken === 'new-access')
+ );
+
+ /** @var Geocaching&MockObject $provider */
+ $provider = $this->createMock(Geocaching::class);
+ $provider->expects($this->once())
+ ->method('getAccessToken')
+ ->with('refresh_token', ['refresh_token' => 'refresh-token'])
+ ->willReturn(new AccessToken(['access_token' => 'new-access', 'refresh_token' => 'new-refresh', 'expires' => time() + 3600]));
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $capturedRequest = null;
+ $next = fn ($request) => new FulfilledPromise(new Response(401));
+ $first = function ($request) use (&$capturedRequest) {
+ $capturedRequest = $request;
+ return new FulfilledPromise(new Response(200));
+ };
+
+ $response = $plugin->handleRequest(new Request('GET', 'https://example.com'), $next, $first)->wait();
+
+ self::assertSame(200, $response->getStatusCode(), 'Should return retried response');
+ self::assertSame('Bearer new-access', $capturedRequest->getHeaderLine('Authorization'));
+ }
+
+ public function testReturnsExistingTokenWhenNotExpiredAndNoForceRefresh(): void
+ {
+ $reference = 'PR1';
+ $initialTokens = TokenSet::create('old-access', 'refresh-token', 3600);
+
+ /** @var TokenStorageInterface&MockObject $storage */
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->expects($this->once())->method('lockUser')->with($reference)->willReturn(true);
+ $storage->expects($this->once())->method('unlockUser')->with($reference);
+ $storage->expects($this->once())->method('getTokens')->with($reference)->willReturn($initialTokens);
+ $storage->expects($this->never())->method('saveTokens');
+
+ /** @var Geocaching&MockObject $provider */
+ $provider = $this->createMock(Geocaching::class);
+ $provider->expects($this->never())->method('getAccessToken');
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshAccessToken');
+
+ /** @var TokenSet $result */
+ $result = $method->invoke($plugin, false);
+
+ self::assertSame('old-access', $result->accessToken);
+ }
+
+ public function testHandleRequestPassThroughOnSuccessResponse(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->method('lockUser')->willReturn(true);
+ $storage->method('unlockUser');
+ $storage->method('getTokens')->willReturn(TokenSet::create('token', 'refresh', 3600));
+
+ $provider = $this->createMock(Geocaching::class);
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $next = fn ($request) => new FulfilledPromise(new Response(200));
+
+ $response = $plugin->handleRequest(new Request('GET', 'https://example.com'), $next, $next)->wait();
+
+ self::assertSame(200, $response->getStatusCode(), 'Non-401 responses should pass through untouched');
+ }
+
+ public function testRefreshAccessTokenThrowsWhenLockUnavailable(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->expects($this->once())->method('lockUser')->with($reference)->willReturn(false);
+
+ $provider = $this->createMock(Geocaching::class);
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider, null, 1);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshAccessToken');
+
+ $this->expectException(\League\OAuth2\Client\Exception\TokenStorageException::class);
+ $method->invoke($plugin, false);
+ }
+
+ public function testPerformTokenRefreshThrowsWhenNoTokensInStorage(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->expects($this->once())->method('getTokens')->with($reference)->willReturn(null);
+
+ $provider = $this->createMock(Geocaching::class);
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'performTokenRefresh');
+
+ $this->expectException(\League\OAuth2\Client\Exception\TokenStorageException::class);
+ $method->invoke($plugin, false);
+ }
+
+ public function testRefreshTokenAndRetryPropagatesTokenRefreshException(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->method('lockUser')->willReturn(true);
+ $storage->method('unlockUser');
+ $storage->method('getTokens')->willReturn(TokenSet::create('expired', 'refresh-token', -10));
+
+ $provider = $this->createMock(Geocaching::class);
+ $provider->method('getAccessToken')->willThrowException(new \RuntimeException('boom'));
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $this->expectException(TokenRefreshException::class);
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshTokenAndRetry');
+ $method->invoke($plugin, new Request('GET', 'https://example.com'), fn ($req) => new FulfilledPromise(new Response(200)));
+ }
+
+ public function testRefreshTokenAndRetryThrowsWhenRefreshTokenExpired(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->method('lockUser')->willReturn(true);
+ $storage->method('unlockUser');
+ $storage->method('getTokens')->willReturn(TokenSet::create('expired', 'refresh-token', -10));
+
+ $response = new Response(400, [], json_encode(['error' => 'invalid_grant', 'error_description' => 'expired']));
+ $provider = $this->createMock(Geocaching::class);
+ $provider->method('getAccessToken')->willThrowException(
+ GeocachingIdentityProviderException::oauthException($response, ['error' => 'invalid_grant'])
+ );
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $this->expectException(RefreshTokenExpiredException::class);
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshTokenAndRetry');
+ $method->invoke($plugin, new Request('GET', 'https://example.com'), fn ($req) => new FulfilledPromise(new Response(200)));
+ }
+
+ public function testRefreshAccessTokenReturnsTokenFromOtherProcess(): void
+ {
+ $reference = 'PR1';
+ $fresh = TokenSet::create('fresh', 'refresh-token', 3600);
+
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->expects($this->once())->method('lockUser')->willReturn(false);
+ $storage->expects($this->once())->method('getTokens')->willReturn($fresh);
+
+ $provider = $this->createMock(Geocaching::class);
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshAccessToken');
+
+ $result = $method->invoke($plugin, false);
+
+ self::assertSame('fresh', $result->accessToken);
+ }
+
+ public function testCallOAuthRefreshEndpointMapsProviderErrorToTokenRefreshException(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->method('lockUser')->willReturn(true);
+ $storage->method('unlockUser');
+ $storage->method('getTokens')->willReturn(TokenSet::create('expired', 'refresh-token', -10));
+
+ $response = new Response(400, [], json_encode(['error' => 'server_error', 'message' => 'bad']));
+ $provider = $this->createMock(Geocaching::class);
+ $provider->method('getAccessToken')->willThrowException(
+ GeocachingIdentityProviderException::oauthException($response, ['error' => 'server_error'])
+ );
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'callOAuthRefreshEndpoint');
+
+ $this->expectException(TokenRefreshException::class);
+ $method->invoke($plugin, 'refresh-token');
+ }
+
+ public function testCallOAuthRefreshEndpointWrapsUnexpectedException(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $provider = $this->createMock(Geocaching::class);
+ $provider->method('getAccessToken')->willThrowException(new \RuntimeException('network down'));
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'callOAuthRefreshEndpoint');
+
+ $this->expectException(TokenRefreshException::class);
+ $method->invoke($plugin, 'refresh-token');
+ }
+
+ public function testHandleRequestPropagatesPromiseRejection(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $provider = $this->createMock(Geocaching::class);
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $next = fn ($request) => new RejectedPromise(new \RuntimeException('fail'));
+
+ $this->expectException(\RuntimeException::class);
+ $plugin->handleRequest(new Request('GET', 'https://example.com'), $next, $next)->wait();
+ }
+
+ public function testRefreshTokenAndRetryPropagatesRetryFailure(): void
+ {
+ $reference = 'PR1';
+ $storage = $this->createMock(TokenStorageInterface::class);
+ $storage->method('lockUser')->willReturn(true);
+ $storage->method('unlockUser');
+ $storage->method('getTokens')->willReturn(TokenSet::create('expired', 'refresh-token', -10));
+
+ $provider = $this->createMock(Geocaching::class);
+ $provider->method('getAccessToken')->willReturn(new AccessToken(['access_token' => 'new', 'refresh_token' => 'refresh', 'expires' => time() + 3600]));
+
+ $plugin = new TokenRefreshPlugin($reference, $storage, $provider);
+
+ $method = new \ReflectionMethod(TokenRefreshPlugin::class, 'refreshTokenAndRetry');
+
+ $this->expectException(\RuntimeException::class);
+ $method->invoke($plugin, new Request('GET', 'https://example.com'), fn ($req) => new RejectedPromise(new \RuntimeException('retry fail')))->wait();
+ }
+}
diff --git a/test/src/Provider/GeocachingConfigTest.php b/test/src/Provider/GeocachingConfigTest.php
new file mode 100644
index 0000000..9f77c16
--- /dev/null
+++ b/test/src/Provider/GeocachingConfigTest.php
@@ -0,0 +1,100 @@
+assertArrayHasKey('domain', $config);
+ $this->assertArrayHasKey('apiDomain', $config);
+ $this->assertArrayHasKey('oAuthDomain', $config);
+ $this->assertEquals('https://www.geocaching.com', $config['domain']);
+ $this->assertEquals('https://api.groundspeak.com', $config['apiDomain']);
+ $this->assertEquals('https://oauth.geocaching.com', $config['oAuthDomain']);
+ }
+
+ public function testGetEnvironmentConfigThrowsForInvalidEnvironment(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid environment "invalid"');
+
+ GeocachingConfig::getEnvironmentConfig('invalid');
+ }
+
+ public function testIsValidEnvironmentReturnsTrueForValidEnvironments(): void
+ {
+ foreach (GeocachingConfig::VALID_ENVIRONMENTS as $environment) {
+ $this->assertTrue(
+ GeocachingConfig::isValidEnvironment($environment),
+ "Environment '{$environment}' should be valid"
+ );
+ }
+ }
+
+ public function testIsValidEnvironmentReturnsFalseForInvalidEnvironment(): void
+ {
+ $this->assertFalse(GeocachingConfig::isValidEnvironment('invalid'));
+ }
+
+ public function testGetValidEnvironmentsReturnsAllValidEnvironments(): void
+ {
+ $validEnvironments = GeocachingConfig::getValidEnvironments();
+
+ $this->assertIsArray($validEnvironments);
+ $this->assertContains('dev', $validEnvironments);
+ $this->assertContains('production', $validEnvironments);
+ $this->assertContains('staging', $validEnvironments);
+ $this->assertContains('test', $validEnvironments);
+ }
+
+ public function testDevEnvironmentConfiguration(): void
+ {
+ $config = GeocachingConfig::getEnvironmentConfig('dev');
+
+ $this->assertEquals('http://localhost:8000', $config['domain']);
+ $this->assertEquals('http://localhost:8000', $config['apiDomain']);
+ $this->assertEquals('http://localhost:8000', $config['oAuthDomain']);
+ }
+
+ public function testStagingEnvironmentConfiguration(): void
+ {
+ $config = GeocachingConfig::getEnvironmentConfig('staging');
+
+ $this->assertEquals('https://staging.geocaching.com', $config['domain']);
+ $this->assertEquals('https://staging.api.groundspeak.com', $config['apiDomain']);
+ $this->assertEquals('https://oauth-staging.geocaching.com', $config['oAuthDomain']);
+ }
+
+ public function testcreateMergesOverrides(): void
+ {
+ $customConfig = GeocachingConfig::create('dev', [
+ 'apiDomain' => 'https://custom-api.example.com',
+ ]);
+
+ $this->assertEquals('http://localhost:8000', $customConfig['domain']); // from dev base
+ $this->assertEquals('https://custom-api.example.com', $customConfig['apiDomain']); // custom override
+ $this->assertEquals('http://localhost:8000', $customConfig['oAuthDomain']); // from dev base
+ }
+
+ public function testcreateFiltersEmptyOverrides(): void
+ {
+ $customConfig = GeocachingConfig::create('production', [
+ 'domain' => 'https://custom.example.com',
+ 'apiDomain' => '', // Should be filtered out
+ 'oAuthDomain' => null, // Should be filtered out
+ ]);
+
+ $this->assertEquals('https://custom.example.com', $customConfig['domain']);
+ $this->assertEquals('https://api.groundspeak.com', $customConfig['apiDomain']); // production default
+ $this->assertEquals('https://oauth.geocaching.com', $customConfig['oAuthDomain']); // production default
+ }
+}
\ No newline at end of file
diff --git a/test/src/Provider/GeocachingCustomUrlTest.php b/test/src/Provider/GeocachingCustomUrlTest.php
new file mode 100644
index 0000000..bfa0860
--- /dev/null
+++ b/test/src/Provider/GeocachingCustomUrlTest.php
@@ -0,0 +1,157 @@
+ 'test-client',
+ 'clientSecret' => 'test-secret',
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://localhost/callback',
+ 'domain' => $customDomain,
+ 'apiDomain' => $customApiDomain,
+ 'oAuthDomain' => $customOAuthDomain,
+ ]);
+
+ $this->assertEquals($customDomain, $this->getProperty($provider, 'domain'));
+ $this->assertEquals($customApiDomain, $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals($customOAuthDomain, $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testConstructorWithPartialCustomUrls(): void
+ {
+ $customApiDomain = 'https://my-api.example.com';
+
+ $provider = new Geocaching([
+ 'clientId' => 'test-client',
+ 'clientSecret' => 'test-secret',
+ 'environment' => 'dev',
+ 'redirectUri' => 'http://localhost/callback',
+ 'apiDomain' => $customApiDomain,
+ // domain and oAuthDomain should use dev environment defaults
+ ]);
+
+ // Custom override
+ $this->assertEquals($customApiDomain, $this->getProperty($provider, 'apiDomain'));
+
+ // Environment defaults (dev)
+ $this->assertEquals('http://localhost:8000', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('http://localhost:8000', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testConstructorFallsBackToEnvironmentDefaults(): void
+ {
+ $provider = new Geocaching([
+ 'clientId' => 'test-client',
+ 'clientSecret' => 'test-secret',
+ 'environment' => 'production',
+ 'redirectUri' => 'http://localhost/callback',
+ // No custom URLs provided
+ ]);
+
+ // Should use production environment defaults
+ $this->assertEquals('https://www.geocaching.com', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('https://api.groundspeak.com', $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals('https://oauth.geocaching.com', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testFactoryCreateWithCustomUrls(): void
+ {
+ $provider = GeocachingTestFactory::createWithCustomUrls(
+ 'https://dev.geocaching.local',
+ 'https://api.dev.geocaching.local',
+ 'https://oauth.dev.geocaching.local'
+ );
+
+ $this->assertEquals('https://dev.geocaching.local', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('https://api.dev.geocaching.local', $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals('https://oauth.dev.geocaching.local', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testFactoryCreateForDocker(): void
+ {
+ $provider = GeocachingTestFactory::createForDocker(9000);
+
+ $this->assertEquals('http://localhost:9000', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('http://localhost:9000/api', $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals('http://localhost:9000/oauth', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testFactoryCreateForLocalDev(): void
+ {
+ $provider = GeocachingTestFactory::createForLocalDev('http://192.168.1.100:8080');
+
+ $this->assertEquals('http://192.168.1.100:8080', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('http://192.168.1.100:8080/api/v1', $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals('http://192.168.1.100:8080/oauth', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
+ public function testCustomUrlsAffectGeneratedUrls(): void
+ {
+ $provider = GeocachingTestFactory::createWithCustomUrls(
+ 'https://my-geocaching.local',
+ 'https://api.my-geocaching.local',
+ 'https://oauth.my-geocaching.local'
+ );
+
+ $this->assertEquals(
+ 'https://my-geocaching.local/oauth/authorize.aspx',
+ $provider->getBaseAuthorizationUrl()
+ );
+
+ $this->assertEquals(
+ 'https://oauth.my-geocaching.local/token',
+ $provider->getBaseAccessTokenUrl([])
+ );
+
+ // ResourceOwnerDetailsUrl should use the apiDomain
+ $mockToken = new \League\OAuth2\Client\Token\AccessToken(['access_token' => 'test']);
+ $resourceOwnerUrl = $provider->getResourceOwnerDetailsUrl($mockToken);
+ $this->assertStringStartsWith('https://api.my-geocaching.local/v1/users/me', $resourceOwnerUrl);
+ }
+
+ public function testGeocachingConfigcreate(): void
+ {
+ $customConfig = GeocachingConfig::create('dev', [
+ 'apiDomain' => 'https://custom-api.example.com',
+ ]);
+
+ $this->assertEquals('http://localhost:8000', $customConfig['domain']); // from dev environment
+ $this->assertEquals('https://custom-api.example.com', $customConfig['apiDomain']); // custom override
+ $this->assertEquals('http://localhost:8000', $customConfig['oAuthDomain']); // from dev environment
+ }
+
+ public function testGeocachingConfigcreateFiltersEmptyValues(): void
+ {
+ $customConfig = GeocachingConfig::create('production', [
+ 'domain' => 'https://custom-domain.com',
+ 'apiDomain' => '', // Empty string should be filtered out
+ 'oAuthDomain' => null, // Null should be filtered out
+ ]);
+
+ $this->assertEquals('https://custom-domain.com', $customConfig['domain']); // custom override
+ $this->assertEquals('https://api.groundspeak.com', $customConfig['apiDomain']); // production default (not overridden)
+ $this->assertEquals('https://oauth.geocaching.com', $customConfig['oAuthDomain']); // production default (not overridden)
+ }
+
+ private function getProperty(object $object, string $name): mixed
+ {
+ $property = new ReflectionProperty($object, $name);
+ return $property->getValue($object);
+ }
+}
\ No newline at end of file
diff --git a/test/src/Provider/GeocachingTest.php b/test/src/Provider/GeocachingTest.php
index 9fee835..c77d60f 100644
--- a/test/src/Provider/GeocachingTest.php
+++ b/test/src/Provider/GeocachingTest.php
@@ -8,12 +8,13 @@
use League\OAuth2\Client\Provider\Geocaching as GeocachingProvider;
use League\OAuth2\Client\Provider\GeocachingResourceOwner;
use League\OAuth2\Client\Token\AccessToken;
-use Mockery;
use PHPUnit\Framework\TestCase;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use Psr\Http\Message\ResponseInterface;
use ReflectionClass;
use ReflectionProperty;
+#[AllowMockObjectsWithoutExpectations]
class GeocachingTest extends TestCase
{
@@ -63,7 +64,6 @@ public function testConfigurableOptions()
foreach ($options as $key => $expected) {
$property = new ReflectionProperty(GeocachingProvider::class, $key);
- $property->setAccessible(true);
$this->assertEquals($expected, $property->getValue($provider));
}
@@ -76,7 +76,6 @@ public function testConfigurableOptions()
$reflection = new ReflectionClass(get_class($provider));
$getPkceMethod = $reflection->getMethod('getPkceMethod');
- $getPkceMethod->setAccessible(true);
$this->assertEquals($options['pkceMethod'], $getPkceMethod->invoke($provider));
}
@@ -98,11 +97,26 @@ public function testGetConfigurableOptions()
$reflection = new ReflectionClass(get_class($provider));
$getConfigurableOptions = $reflection->getMethod('getConfigurableOptions');
- $getConfigurableOptions->setAccessible(true);
$this->assertIsArray($getConfigurableOptions->invoke($provider));
}
+ public function testStagingEnvironmentDomains()
+ {
+ $provider = new GeocachingProvider([
+ 'clientId' => 'mock_client_id',
+ 'clientSecret' => 'mock_secret',
+ 'environment' => 'staging',
+ 'pkceMethod' => 'S256',
+ 'redirectUri' => 'none',
+ 'scope' => '*',
+ ]);
+
+ $this->assertEquals('https://staging.geocaching.com', $this->getProperty($provider, 'domain'));
+ $this->assertEquals('https://staging.api.groundspeak.com', $this->getProperty($provider, 'apiDomain'));
+ $this->assertEquals('https://oauth-staging.geocaching.com', $this->getProperty($provider, 'oAuthDomain'));
+ }
+
public function testResourceOwnerDetails()
{
$token = new AccessToken(['access_token' => 'mock_token']);
@@ -148,11 +162,17 @@ public function testResourceOwnerDetails()
$this->assertEquals('https://coord.info/PR1QQQP', $data['url']);
}
+ private function getProperty(object $object, string $name): mixed
+ {
+ $property = new ReflectionProperty($object, $name);
+
+ return $property->getValue($object);
+ }
+
public function testCheckResponse()
{
- $mockedResponse = Mockery::mock(ResponseInterface::class);
- // $response->shouldIgnoreMissing();
- $mockedResponse->shouldReceive('getStatusCode');
+ $mockedResponse = $this->createMock(ResponseInterface::class);
+ $mockedResponse->expects($this->any())->method('getStatusCode')->willReturn(200);
$options = [
'clientId' => 'mock_client_id',
@@ -166,17 +186,15 @@ public function testCheckResponse()
$reflection = new ReflectionClass(get_class($provider));
$checkResponse = $reflection->getMethod('checkResponse');
- $checkResponse->setAccessible(true);
$this->assertNull($checkResponse->invokeArgs($provider, [$mockedResponse, []]));
}
public function testCheckResponseWithError()
{
- $mockedResponse = Mockery::mock(ResponseInterface::class);
- // $response->shouldIgnoreMissing();
- $mockedResponse->shouldNotReceive('getStatusCode');
- $mockedResponse->shouldReceive('getBody');
+ $mockedResponse = $this->createMock(ResponseInterface::class);
+ $mockedResponse->expects($this->once())->method('getStatusCode')->willReturn(400);
+ $mockedResponse->expects($this->any())->method('getBody')->willReturn($this->createMock(\Psr\Http\Message\StreamInterface::class));
$options = [
'clientId' => 'mock_client_id',
@@ -190,19 +208,18 @@ public function testCheckResponseWithError()
$reflection = new ReflectionClass(get_class($provider));
$checkResponse = $reflection->getMethod('checkResponse');
- $checkResponse->setAccessible(true);
$this->expectException(GeocachingIdentityProviderException::class);
- $checkResponse->invokeArgs($provider, [$mockedResponse, ['error' => 'Bad rssequest']]);
+ $checkResponse->invokeArgs($provider, [$mockedResponse, ['error' => 'Bad request']]);
}
public function testCheckResponseWithClientError()
{
- $mockedResponse = Mockery::mock(ResponseInterface::class);
- $mockedResponse->shouldReceive('getStatusCode')->andReturn(401);
- $mockedResponse->shouldReceive('getReasonPhrase')->andReturn('Unauthorized');
- $mockedResponse->shouldReceive('getBody');
+ $mockedResponse = $this->createMock(ResponseInterface::class);
+ $mockedResponse->expects($this->exactly(2))->method('getStatusCode')->willReturn(401);
+ $mockedResponse->expects($this->once())->method('getReasonPhrase')->willReturn('Unauthorized');
+ $mockedResponse->expects($this->any())->method('getBody')->willReturn($this->createMock(\Psr\Http\Message\StreamInterface::class));
$options = [
'clientId' => 'mock_client_id',
@@ -216,7 +233,6 @@ public function testCheckResponseWithClientError()
$reflection = new ReflectionClass(get_class($provider));
$checkResponse = $reflection->getMethod('checkResponse');
- $checkResponse->setAccessible(true);
$this->expectException(GeocachingIdentityProviderException::class);
diff --git a/test/src/Token/TokenSetTest.php b/test/src/Token/TokenSetTest.php
new file mode 100644
index 0000000..30505dd
--- /dev/null
+++ b/test/src/Token/TokenSetTest.php
@@ -0,0 +1,67 @@
+ 'abc',
+ 'refresh_token' => 'def',
+ 'expires_in' => 120,
+ 'token_type' => 'Bearer',
+ 'scope' => 'foo bar',
+ ]);
+
+ self::assertSame('abc', $tokens->accessToken);
+ self::assertSame('def', $tokens->refreshToken);
+ self::assertSame('Bearer abc', $tokens->getAuthorizationHeader());
+ self::assertGreaterThan(0, $tokens->getSecondsUntilExpiry());
+ self::assertSame(['foo', 'bar'], $tokens->scopes);
+ }
+
+ public function testFromOAuthResponseRequiresRefreshToken(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ TokenSet::fromOAuthResponse(['access_token' => 'abc'], null);
+ }
+
+ public function testCreateThrowsOnEmptyValues(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ TokenSet::create('', 'refresh');
+ }
+
+ public function testCreateThrowsOnEmptyRefreshToken(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ TokenSet::create('access', '');
+ }
+
+ public function testIsExpiredAndBuffer(): void
+ {
+ $tokens = TokenSet::create('access', 'refresh', 1);
+ sleep(2);
+
+ self::assertTrue($tokens->isExpired(), 'Token should be expired after sleep');
+ }
+
+ public function testRoundTripArraySerialization(): void
+ {
+ $original = TokenSet::create('access', 'refresh', 300);
+ $array = $original->toArray();
+ $restored = TokenSet::fromArray($array);
+
+ self::assertSame($original->accessToken, $restored->accessToken);
+ self::assertSame($original->refreshToken, $restored->refreshToken);
+ self::assertSame($original->tokenType, $restored->tokenType);
+ self::assertSame($original->expiresAt->format('Y-m-d H:i'), $restored->expiresAt->format('Y-m-d H:i'));
+ }
+}