Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,31 @@ 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"

coding-standards:
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"

Expand All @@ -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"
Expand All @@ -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"
uses: "codecov/codecov-action@v5"
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
258 changes: 257 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 11 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading