From b4f1f98a81c4c7475f54b1a412d3fa169ed4f6cf Mon Sep 17 00:00:00 2001 From: Surfoo Date: Mon, 15 Dec 2025 12:31:48 +0100 Subject: [PATCH 1/4] Access Token with autorefresh --- .github/workflows/ci.yml | 14 +- CHANGELOG.md | 11 + README.md | 55 ++++ composer.json | 17 +- demo/index.php | 63 ++++- .../RefreshTokenExpiredException.php | 9 + src/Exception/TokenRefreshException.php | 32 +++ src/Exception/TokenStorageException.php | 9 + src/Plugin/TokenRefreshPlugin.php | 258 ++++++++++++++++++ .../GeocachingIdentityProviderException.php | 18 +- src/Token/TokenSet.php | 128 +++++++++ src/Token/TokenStorageInterface.php | 54 ++++ 12 files changed, 641 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/Exception/RefreshTokenExpiredException.php create mode 100644 src/Exception/TokenRefreshException.php create mode 100644 src/Exception/TokenStorageException.php create mode 100644 src/Plugin/TokenRefreshPlugin.php create mode 100644 src/Token/TokenSet.php create mode 100644 src/Token/TokenStorageInterface.php 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..77a012c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,61 @@ Usage is the same as The League's OAuth client, using `\League\OAuth2\Client\Pro Take a look at `demo/index.php` +### 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 ``` bash diff --git a/composer.json b/composer.json index 1543b24..85b33fe 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" }, "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/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->debug('[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(); + $newRequest = $this->updateRequestWithNewToken($request, $newTokens); + + $this->logger->info('[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(): 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(); + } finally { + $this->storage->unlockUser($this->referenceCode); + } + } + + // Another process is refreshing, wait and check if they succeeded + if ($attempt < $this->maxRetryAttempts - 1) { + usleep(self::LOCK_WAIT_MICROSECONDS[$attempt] ?? 500000); + + // 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(): TokenSet + { + // Get current tokens + $currentTokens = $this->storage->getTokens($this->referenceCode); + if (!$currentTokens) { + throw new TokenStorageException("No tokens found for user {$this->referenceCode}"); + } + + // Check if token was already refreshed by another process + if (!$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()); + } +} 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/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 @@ + Date: Wed, 17 Dec 2025 11:47:10 +0100 Subject: [PATCH 2/4] wip --- composer.json | 4 +- phpunit.xml | 3 + src/Plugin/TokenRefreshPlugin.php | 13 +- .../Exception/TokenRefreshExceptionTest.php | 38 +++ test/src/Plugin/TokenRefreshPluginTest.php | 268 ++++++++++++++++++ test/src/Provider/GeocachingTest.php | 52 ++-- test/src/Token/TokenSetTest.php | 67 +++++ 7 files changed, 419 insertions(+), 26 deletions(-) create mode 100644 test/src/Exception/TokenRefreshExceptionTest.php create mode 100644 test/src/Plugin/TokenRefreshPluginTest.php create mode 100644 test/src/Token/TokenSetTest.php diff --git a/composer.json b/composer.json index 85b33fe..c5ca20d 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,13 @@ "psr/log": "^3.0" }, "require-dev": { - "mockery/mockery": "~1.4", "php-parallel-lint/php-parallel-lint": "~1.3", "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" + "squizlabs/php_codesniffer": "~4.0", + "nyholm/psr7": "^1.8" }, "autoload": { "psr-4": { 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/Plugin/TokenRefreshPlugin.php b/src/Plugin/TokenRefreshPlugin.php index bf1cc65..fb862ec 100644 --- a/src/Plugin/TokenRefreshPlugin.php +++ b/src/Plugin/TokenRefreshPlugin.php @@ -71,7 +71,7 @@ function (\Throwable $exception) { private function refreshTokenAndRetry(RequestInterface $request, callable $first): Promise { try { - $newTokens = $this->refreshAccessToken(); + $newTokens = $this->refreshAccessToken(forceRefresh: true); $newRequest = $this->updateRequestWithNewToken($request, $newTokens); $this->logger->info('[GEOCACHING] Token refreshed successfully, retrying request', [ @@ -106,13 +106,13 @@ function (\Throwable $exception) { /** * Refresh the access token with concurrency protection. */ - private function refreshAccessToken(): TokenSet + 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(); + return $this->performTokenRefresh($forceRefresh); } finally { $this->storage->unlockUser($this->referenceCode); } @@ -139,7 +139,7 @@ private function refreshAccessToken(): TokenSet /** * Perform the actual token refresh (must be called within lock). */ - private function performTokenRefresh(): TokenSet + private function performTokenRefresh(bool $forceRefresh = false): TokenSet { // Get current tokens $currentTokens = $this->storage->getTokens($this->referenceCode); @@ -147,8 +147,9 @@ private function performTokenRefresh(): TokenSet throw new TokenStorageException("No tokens found for user {$this->referenceCode}"); } - // Check if token was already refreshed by another process - if (!$currentTokens->isExpired()) { + // 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, ]); diff --git a/test/src/Exception/TokenRefreshExceptionTest.php b/test/src/Exception/TokenRefreshExceptionTest.php new file mode 100644 index 0000000..e744925 --- /dev/null +++ b/test/src/Exception/TokenRefreshExceptionTest.php @@ -0,0 +1,38 @@ + '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/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/GeocachingTest.php b/test/src/Provider/GeocachingTest.php index 9fee835..3f0f4be 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,7 +208,6 @@ public function testCheckResponseWithError() $reflection = new ReflectionClass(get_class($provider)); $checkResponse = $reflection->getMethod('checkResponse'); - $checkResponse->setAccessible(true); $this->expectException(GeocachingIdentityProviderException::class); @@ -199,10 +216,10 @@ public function testCheckResponseWithError() 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')); + } +} From e5e60537ba947a5bdf3f3433863fb6bdb2be8c99 Mon Sep 17 00:00:00 2001 From: Surfoo Date: Wed, 17 Dec 2025 12:29:06 +0100 Subject: [PATCH 3/4] improvement of logs, decoupling code, and tests --- src/Plugin/TokenRefreshPlugin.php | 23 +++- src/Provider/Geocaching.php | 54 +++------- src/Provider/GeocachingConfig.php | 117 +++++++++++++++++++++ test/src/Factory/GeocachingTestFactory.php | 85 +++++++++++++++ test/src/Factory/TokenSetTestFactory.php | 99 +++++++++++++++++ test/src/Provider/GeocachingConfigTest.php | 76 +++++++++++++ test/src/Provider/GeocachingTest.php | 2 +- 7 files changed, 411 insertions(+), 45 deletions(-) create mode 100644 src/Provider/GeocachingConfig.php create mode 100644 test/src/Factory/GeocachingTestFactory.php create mode 100644 test/src/Factory/TokenSetTestFactory.php create mode 100644 test/src/Provider/GeocachingConfigTest.php diff --git a/src/Plugin/TokenRefreshPlugin.php b/src/Plugin/TokenRefreshPlugin.php index fb862ec..efa66eb 100644 --- a/src/Plugin/TokenRefreshPlugin.php +++ b/src/Plugin/TokenRefreshPlugin.php @@ -29,7 +29,9 @@ class TokenRefreshPlugin implements Plugin { private const MAX_RETRY_ATTEMPTS = 3; - private const LOCK_WAIT_MICROSECONDS = [100000, 250000, 500000]; // Progressive backoff + private const BACKOFF_MULTIPLIER = 1.5; + private const MAX_BACKOFF_MS = 2000; + private const BASE_BACKOFF_MS = 100; public function __construct( private string $referenceCode, @@ -47,7 +49,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl function (ResponseInterface $response) use ($request, $first) { // If we get a 401, try to refresh the token and retry if ($response->getStatusCode() === 401) { - $this->logger->debug('[GEOCACHING] Received 401, attempting token refresh', [ + $this->logger->warning('[GEOCACHING] Received 401, attempting token refresh', [ 'reference_code' => $this->referenceCode, 'request_uri' => (string) $request->getUri(), ]); @@ -74,7 +76,7 @@ private function refreshTokenAndRetry(RequestInterface $request, callable $first $newTokens = $this->refreshAccessToken(forceRefresh: true); $newRequest = $this->updateRequestWithNewToken($request, $newTokens); - $this->logger->info('[GEOCACHING] Token refreshed successfully, retrying request', [ + $this->logger->notice('[GEOCACHING] Token refreshed successfully, retrying request', [ 'reference_code' => $this->referenceCode, 'expires_at' => $newTokens->expiresAt->format('Y-m-d H:i:s'), ]); @@ -120,7 +122,7 @@ private function refreshAccessToken(bool $forceRefresh = false): TokenSet // Another process is refreshing, wait and check if they succeeded if ($attempt < $this->maxRetryAttempts - 1) { - usleep(self::LOCK_WAIT_MICROSECONDS[$attempt] ?? 500000); + usleep($this->calculateBackoff($attempt)); // Check if the other process already refreshed the token $tokens = $this->storage->getTokens($this->referenceCode); @@ -256,4 +258,17 @@ private function updateRequestWithNewToken(RequestInterface $request, TokenSet $ { 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/Geocaching.php b/src/Provider/Geocaching.php index ed5bec9..d2937d4 100644 --- a/src/Provider/Geocaching.php +++ b/src/Provider/Geocaching.php @@ -32,32 +32,24 @@ 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. * * @param array $options An array of options to set on this provider. * Options include `clientId`, `clientSecret`, `redirectUri`, and `state`. @@ -70,30 +62,12 @@ class Geocaching extends AbstractProvider 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; - } + $config = GeocachingConfig::getEnvironmentConfig($this->environment); + $this->domain = $config['domain']; + $this->apiDomain = $config['apiDomain']; + $this->oAuthDomain = $config['oAuthDomain']; } /** @@ -116,7 +90,7 @@ protected function getConfigurableOptions(): array * * @return array */ - protected function getRequiredOptions() + protected function getRequiredOptions(): array { return [ 'clientId', @@ -126,7 +100,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..8bc8442 --- /dev/null +++ b/src/Provider/GeocachingConfig.php @@ -0,0 +1,117 @@ + [ + '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; + } +} \ No newline at end of file diff --git a/test/src/Factory/GeocachingTestFactory.php b/test/src/Factory/GeocachingTestFactory.php new file mode 100644 index 0000000..1928747 --- /dev/null +++ b/test/src/Factory/GeocachingTestFactory.php @@ -0,0 +1,85 @@ + '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', + ]); + } +} \ 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/Provider/GeocachingConfigTest.php b/test/src/Provider/GeocachingConfigTest.php new file mode 100644 index 0000000..317d6e0 --- /dev/null +++ b/test/src/Provider/GeocachingConfigTest.php @@ -0,0 +1,76 @@ +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']); + } +} \ No newline at end of file diff --git a/test/src/Provider/GeocachingTest.php b/test/src/Provider/GeocachingTest.php index 3f0f4be..c77d60f 100644 --- a/test/src/Provider/GeocachingTest.php +++ b/test/src/Provider/GeocachingTest.php @@ -211,7 +211,7 @@ public function testCheckResponseWithError() $this->expectException(GeocachingIdentityProviderException::class); - $checkResponse->invokeArgs($provider, [$mockedResponse, ['error' => 'Bad rssequest']]); + $checkResponse->invokeArgs($provider, [$mockedResponse, ['error' => 'Bad request']]); } public function testCheckResponseWithClientError() From 9c8d871da7b4011e9afdf82795e25418d3299698 Mon Sep 17 00:00:00 2001 From: Surfoo Date: Wed, 17 Dec 2025 12:41:09 +0100 Subject: [PATCH 4/4] custom config for overrides URLs --- README.md | 203 +++++++++++++++++- src/Provider/Geocaching.php | 40 +++- src/Provider/GeocachingConfig.php | 29 ++- test/src/Factory/GeocachingTestFactory.php | 46 ++++ test/src/Provider/GeocachingConfigTest.php | 24 +++ test/src/Provider/GeocachingCustomUrlTest.php | 157 ++++++++++++++ 6 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 test/src/Provider/GeocachingCustomUrlTest.php diff --git a/README.md b/README.md index 77a012c..5cff477 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,208 @@ 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 diff --git a/src/Provider/Geocaching.php b/src/Provider/Geocaching.php index d2937d4..04a4beb 100644 --- a/src/Provider/Geocaching.php +++ b/src/Provider/Geocaching.php @@ -49,25 +49,48 @@ class Geocaching extends AbstractProvider private array $resourceOwnerFields; /** - * Constructs an OAuth 2.0 service provider. + * 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); + // Get environment defaults $config = GeocachingConfig::getEnvironmentConfig($this->environment); - $this->domain = $config['domain']; - $this->apiDomain = $config['apiDomain']; - $this->oAuthDomain = $config['oAuthDomain']; + + // 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']; } /** @@ -82,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 ]); } diff --git a/src/Provider/GeocachingConfig.php b/src/Provider/GeocachingConfig.php index 8bc8442..fc85e22 100644 --- a/src/Provider/GeocachingConfig.php +++ b/src/Provider/GeocachingConfig.php @@ -114,4 +114,31 @@ public static function getValidEnvironments(): array { return self::VALID_ENVIRONMENTS; } -} \ No newline at end of file + + /** + * 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/test/src/Factory/GeocachingTestFactory.php b/test/src/Factory/GeocachingTestFactory.php index 1928747..4c8fd7d 100644 --- a/test/src/Factory/GeocachingTestFactory.php +++ b/test/src/Factory/GeocachingTestFactory.php @@ -82,4 +82,50 @@ public static function createMinimal(): Geocaching '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/Provider/GeocachingConfigTest.php b/test/src/Provider/GeocachingConfigTest.php index 317d6e0..9f77c16 100644 --- a/test/src/Provider/GeocachingConfigTest.php +++ b/test/src/Provider/GeocachingConfigTest.php @@ -73,4 +73,28 @@ public function testStagingEnvironmentConfiguration(): void $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