diff --git a/CHANGELOG.md b/CHANGELOG.md index 5691fe6..69aa335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,80 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [1.5.0] - 2026-08-21 + +### Added +- **Client-side token expiration tracking** for FamilySearch OAuth tokens + - New `isTokenExpired()` method returns true/false for token expiration status + - New `getTokenExpirationTime()` method returns Unix timestamp when token expires + - New `setAccessToken($token, $expiresIn)` method with optional expiration parameter + - Enhanced `getAccessToken($detailed)` with optional detailed mode returning array with: + - `token`: OAuth access token string + - `created`: Unix timestamp when token was created + - `last_activity`: Unix timestamp of last successful API call + - `expires_at`: Unix timestamp when token will expire + - `is_expired`: Boolean indicating if token is expired or expiring soon + - New `expirationWarningThreshold` configuration option (default: 300 seconds / 5 minutes) + - Automatic activity tracking - updates last activity timestamp on successful API calls + - Session format now stores token with metadata (creation time, last activity) + - Tracks two expiration conditions: + - Absolute expiration: 24 hours from token creation + - Inactivity expiration: 60 minutes from last API call +- **Authentication failure callback system** for handling 401 responses + - New `onAuthenticationFailure` configuration option accepts callable/callback + - Callback receives response object and failure reason (`'expired'` or `'invalid'`) + - Allows automatic re-authentication on token expiration + - Supports password grant re-authentication in callback + - Supports redirect to login page for authorization code flow + - Callback can use `setAccessToken()` or `oauthPassword()` to obtain new token + - Backward compatible - callback is optional, existing error handling preserved +- **Automatic request replay after re-authentication** + - New `replayFailedRequestsAfterAuth` configuration option (defaults to `true`) + - Automatically retries failed requests after successful re-authentication via callback + - Single retry only to prevent infinite loops + - Response includes `replayed` flag and `originalResponse` when request was retried + - Can be disabled for manual retry handling +- Comprehensive documentation in `docs/TOKEN_EXPIRATION.md` + - Three approaches to token expiration handling + - Proactive expiration checking examples + - Authentication failure callback patterns + - Enhanced token info retrieval examples +- Complete test coverage for token expiration scenarios + - `tests/Unit/FamilySearchTokenExpirationTest.php` - 28 unit tests + - `tests/Unit/FamilySearchAuthCallbackTest.php` - 18 unit tests for callback system + - `tests/Unit/FamilySearchRequestReplayTest.php` - 17 unit tests for replay functionality + - `tests/Integration/AuthenticationCallbackTest.php` - Integration tests + - `tests/Integration/RequestReplayTest.php` - Integration tests + - `tests/Integration/TokenExpirationComprehensiveTest.php` - End-to-end tests + +### Changed +- Session storage format enhanced with token metadata (backward compatible) +- Existing sessions auto-migrate to new format with timestamp initialization +- OAuth response handler now initializes token timestamps +- Successful API calls (status code < 400, not 401) automatically update last activity +- Internal token storage includes creation time and last activity tracking +- Internal request handling enhanced to invoke callback before returning 401 responses +- Callback exceptions are caught and logged to prevent SDK instability +- Enhanced internal request handling to support retry logic + +### Fixed +- Token expiration now properly detected before API calls fail +- 401 responses can be handled gracefully via callback instead of throwing exceptions + +### Security +- Client-side expiration tracking prevents unnecessary 401 errors +- Proactive re-authentication improves security posture +- No tokens transmitted for validation - all expiration logic is client-side +- Transparent recovery from token expiration improves user experience +- Prevents exposure of 401 errors to end users when tokens can be refreshed + +## [1.4.0] - 2026-08-18 ### Added - PHP 7.4 minimum version support with modern type hints - Support for PHP 8.0, 8.1, 8.2, 8.3, and 8.4 - Return type declarations to helper functions in examples -- Comprehensive TESTING.md documentation for application testing +- Comprehensive docs/TESTING.md documentation for application testing - GitHub Actions workflow for automated testing across PHP 7.4-8.4 - Code coverage reporting (76.33% coverage) @@ -30,7 +97,7 @@ All notable changes to this project will be documented in this file. - Clarified that environment variables must be used for credentials (never hardcode) - Enhanced security documentation in examples/_includes.php -## [1.3.0] - 2024 (Master Branch - Not Yet Released) +## [1.3.0] - 2026-08-07 ### Added - **Optional AES-256-GCM session token encryption** for production deployments @@ -39,7 +106,7 @@ All notable changes to this project will be documented in this file. - Automatic key normalization supporting base64, hex, and raw binary formats - Backward-compatible migration from plaintext to encrypted tokens - Fail-secure behavior (encryption failures never fall back to plaintext) -- Comprehensive SECURITY.md documentation (750+ lines) +- Comprehensive docs/SECURITY.md documentation (750+ lines) - Detailed threat model and security considerations - Key generation and storage best practices - Server configuration guidelines @@ -135,8 +202,9 @@ Initial release of FamilySearch PHP Lite SDK. ## Version History Summary -- **[Unreleased]** - PHP 7.4+ support, terminology updates, enhanced testing -- **[1.3.0]** - Session encryption, comprehensive security documentation (master branch) +- **1.5.0** - Token expiration tracking, authentication callbacks, and automatic request replay +- **1.4.0** - PHP 7.4+ support, terminology updates, enhanced testing +- **1.3.0** - Session encryption, comprehensive security documentation - **1.3.3** - PHP 7 composer fix - **1.3.2** - API subdomain updates - **1.3.1** - License metadata @@ -149,56 +217,35 @@ Initial release of FamilySearch PHP Lite SDK. ## Notes -### Versioning Conflict - -There is a version numbering conflict in the repository: -- Git tag `1.3.0` was used for the gedcomx-php integration (2016) -- Code constant `VERSION = '1.3.0'` was updated for the encryption feature (2024) - -The master branch changes (encryption feature) should be released as **2.0.0** or **1.4.0** to avoid confusion. - ### Migration Guide -#### From 1.3.x to 2.0.0 (Encryption Update) +#### From 1.3.x to 1.4.0 (PHP Version and Testing Updates) -**No breaking changes** - The encryption feature is opt-in: +**No breaking changes** - Version 1.4.0 adds support for PHP 7.4-8.4: ```php // Existing code continues to work unchanged $fs = new FamilySearch([ 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -// Enable encryption (recommended for production) -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'sessionEncryption' => true, - 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] + 'environment' => 'production' // Use 'integration' for testing ]); ``` -Generate encryption key: -```bash -php -r "echo base64_encode(random_bytes(32));" -``` +**Changes:** +- PHP 7.4+ is now supported (previously required PHP 8.0+) +- All "sandbox" references replaced with "integration" terminology +- Enhanced test suite and documentation -See [SECURITY.md](SECURITY.md) for comprehensive encryption setup guide. +#### From 1.3.x to 1.5.0 (Token Expiration Features) + +**New Configuration Options:** +- `expirationWarningThreshold` - Seconds before expiration to warn (default: 300) +- `onAuthenticationFailure` - Callback for handling 401 responses (optional) +- `replayFailedRequestsAfterAuth` - Auto-retry after re-auth (default: true) + +See [docs/TOKEN_EXPIRATION.md](docs/TOKEN_EXPIRATION.md) for comprehensive usage guide. #### From 1.0.x/1.1.x to 1.2.0+ - Update environment references from `'sandbox'` to `'integration'` - Update API endpoint references in documentation - ---- - -[Unreleased]: https://github.com/FamilySearch/fs-php-lite/compare/1.3.3...HEAD -[1.3.0]: https://github.com/FamilySearch/fs-php-lite/compare/1.3.3...master -[1.3.3]: https://github.com/FamilySearch/fs-php-lite/compare/1.3.2...1.3.3 -[1.3.2]: https://github.com/FamilySearch/fs-php-lite/compare/1.3.1...1.3.2 -[1.3.1]: https://github.com/FamilySearch/fs-php-lite/compare/1.3.0...1.3.1 -[1.3.0]: https://github.com/FamilySearch/fs-php-lite/compare/1.2.0...1.3.0 -[1.2.0]: https://github.com/FamilySearch/fs-php-lite/compare/1.1.0...1.2.0 -[1.1.0]: https://github.com/FamilySearch/fs-php-lite/compare/1.0.0...1.1.0 -[1.0.0]: https://github.com/FamilySearch/fs-php-lite/releases/tag/1.0.0 diff --git a/README.md b/README.md index cf0ecba..26b8072 100644 --- a/README.md +++ b/README.md @@ -308,13 +308,88 @@ sudo cat /var/lib/php/sessions/sess_* | grep FS_ACCESS_TOKEN ``` 6. **Regular key rotation** - Rotate encryption keys every 90 days -For comprehensive security guidance, see **[SECURITY.md](SECURITY.md)** which includes: +For comprehensive security guidance, see **[SECURITY.md](docs/SECURITY.md)** which includes: - Detailed threat model - Server configuration best practices - Key rotation procedures - Production deployment checklist - Incident response guidelines +## Token Expiration Handling + +The SDK provides comprehensive token expiration tracking and automatic re-authentication capabilities. FamilySearch access tokens expire based on **two conditions** (whichever comes first): + +1. **Absolute Expiration**: 24 hours from token creation +2. **Inactivity Expiration**: 60 minutes since the last successful API call + +The SDK tracks these conditions client-side and offers **three flexible approaches** to handle token expiration: + +### 1. Proactive Expiration Checking + +Check token expiration **before** making API requests: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'expirationWarningThreshold' => 300 // Warn 5 minutes before expiration +]); + +if ($fs->isTokenExpired()) { + // Token is expired or expiring soon - re-authenticate + $fs->oauthPassword($username, $password); +} + +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +``` + +### 2. Automatic Re-authentication Callback + +Configure a callback to handle 401 responses automatically: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + if ($reason === 'expired') { + // Automatically re-authenticate + $fs->oauthPassword($username, $password); + // SDK automatically retries the original request + } + } +]); + +// Make requests normally - re-authentication happens transparently +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +``` + +### 3. Enhanced Token Information + +Retrieve detailed token metadata for custom handling: + +```php +// Get detailed token information +$tokenInfo = $fs->getAccessToken(true); + +echo "Token expires: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']) . "\n"; +echo "Is expired: " . ($tokenInfo['is_expired'] ? 'Yes' : 'No') . "\n"; + +// Calculate time remaining +$timeRemaining = $tokenInfo['expires_at'] - time(); +$minutesRemaining = floor($timeRemaining / 60); +echo "Time remaining: {$minutesRemaining} minutes\n"; +``` + +### Additional Features + +- **Activity Tracking**: Each successful API call resets the 60-minute inactivity timer +- **Automatic Request Replay**: Failed requests are transparently retried after successful re-authentication +- **Backward Compatible**: Existing code continues to work without changes +- **Configurable Thresholds**: Customize warning thresholds and replay behavior + +### Complete Documentation + +For detailed documentation including additional examples, configuration options, and request replay behavior, see **[TOKEN_EXPIRATION.md](docs/TOKEN_EXPIRATION.md)** + ## Serialization with gedcomx-php When the `objects` configuration option is set to true, the @@ -346,7 +421,7 @@ or later is required. ## Testing -The SDK includes comprehensive unit and integration tests with **76.33% code coverage**. +The SDK includes comprehensive unit and integration tests with **77.47% code coverage**. ### Quick Start @@ -356,6 +431,7 @@ composer install # Run all tests composer test +# Note: If you encounter verbose Xdebug output, use: XDEBUG_MODE=off composer test # Run only unit tests (fast, ~0.01s) composer test:unit @@ -370,17 +446,26 @@ composer test:coverage ### Test Suite Statistics -- **102 tests** with 232 assertions -- **76.33% line coverage**, 50.00% method coverage -- **74 unit tests** - Fast, no HTTP requests -- **28 integration tests** - Test against live FamilySearch integration API +- **202 tests** with 486 assertions +- **77.47% line coverage** (368/475 lines), 50.00% method coverage (15/30 methods) +- **133 unit tests** (244 assertions) - Fast, no HTTP requests +- **69 integration tests** (242 assertions) - Test against live FamilySearch integration API ### Test Structure ``` tests/ -├── Unit/ # 52 tests - SDK logic without HTTP -├── Integration/ # 11 tests - Full SDK against live API +├── Unit/ # 133 tests - SDK logic without HTTP +│ ├── FamilySearchTokenExpirationTest.php # Token expiration tracking +│ ├── FamilySearchAuthCallbackTest.php # Authentication callbacks +│ ├── FamilySearchRequestReplayTest.php # Request replay functionality +│ ├── SessionEncryptionTest.php # Session encryption +│ └── ... (other unit tests) +├── Integration/ # 69 tests - Full SDK against live API +│ ├── TokenExpirationComprehensiveTest.php # Token expiration end-to-end +│ ├── AuthenticationCallbackTest.php # Callback integration +│ ├── RequestReplayTest.php # Request replay integration +│ └── ... (other integration tests) ├── fixtures/ # Test data (person.json) └── bootstrap.php # Test configuration ``` @@ -424,7 +509,7 @@ composer test:coverage open coverage/index.html ``` -See [TESTING.md](TESTING.md) for detailed instructions to create testing for your own application. +See [TESTING.md](docs/TESTING.md) for detailed instructions to create testing for your own application. ## Requirements @@ -454,8 +539,8 @@ Tests run automatically via GitHub Actions on: #### CI Status - ✅ All PHP versions passing (7.4-8.4) -- ✅ 102 tests, 232 assertions -- ✅ 76.33% code coverage (258/338 lines) +- ✅ 202 tests, 486 assertions +- ✅ 77.47% code coverage (368/475 lines) See [.github/workflows/tests.yml](.github/workflows/tests.yml) for CI configuration. diff --git a/SECURITY.md b/docs/SECURITY.md similarity index 100% rename from SECURITY.md rename to docs/SECURITY.md diff --git a/TESTING.md b/docs/TESTING.md similarity index 100% rename from TESTING.md rename to docs/TESTING.md diff --git a/docs/TOKEN_EXPIRATION.md b/docs/TOKEN_EXPIRATION.md new file mode 100644 index 0000000..9446c19 --- /dev/null +++ b/docs/TOKEN_EXPIRATION.md @@ -0,0 +1,209 @@ +# Token Expiration Handling + +## Overview + +The FamilySearch PHP Lite SDK provides three flexible approaches to handle token expiration: + +1. **Proactive Expiration Checking** - Check token status before making requests +2. **Authentication Failure Callbacks** - Automatically handle 401 responses +3. **Enhanced Token Info** - Access detailed token metadata for custom logic + +## FamilySearch Token Behavior + +**FamilySearch access tokens expire based on TWO conditions (whichever comes first):** + +1. **Absolute Expiration**: 24 hours from token creation +2. **Inactivity Expiration**: 60 minutes since the last successful API call + +**Important Characteristics:** +- Each successful API call resets the 60-minute inactivity timer +- No refresh tokens available - you must re-authenticate to get a new token +- No `expires_in` field in OAuth responses - the SDK tracks expiration client-side +- 401 responses don't distinguish between expired, invalid, or revoked tokens + +## Approach 1: Proactive Expiration Checking + +Check token expiration **before** making API requests to re-authenticate proactively. + +### Basic Usage + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'expirationWarningThreshold' => 300 // 5 minutes (default) +]); + +// Check if token is expired or expiring soon +if ($fs->isTokenExpired()) { + $fs->oauthPassword($username, $password); +} + +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +``` + +### Display Expiration Time + +```php +$expirationTime = $fs->getTokenExpirationTime(); + +if ($expirationTime) { + $minutesRemaining = floor(($expirationTime - time()) / 60); + echo "Your session will expire in {$minutesRemaining} minutes\n"; +} +``` + +## Approach 2: Authentication Failure Callback + +Automatically detect and handle 401 responses with a callback. + +### Automatic Re-authentication (Password Grant) + +**Best for:** Background processes, cron jobs, API integrations + +```php +$username = $_ENV['FS_USERNAME']; +$password = $_ENV['FS_PASSWORD']; + +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + if ($reason === 'expired') { + // Re-authenticate with password grant + $fs->oauthPassword($username, $password); + } else { + // Token was revoked or is invalid + throw new Exception('Authentication token is invalid'); + } + } +]); + +// Make requests normally - re-authentication happens automatically +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +// Check if the request was replayed after re-authentication +if ($response->replayed ?? false) { + echo "Request was automatically retried after re-authentication\n"; +} +``` + +### Redirect to Login (Authorization Code Flow) + +**Best for:** Web applications with user interaction + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'redirectUri' => 'https://myapp.com/oauth/callback', + 'environment' => 'production', + 'onAuthenticationFailure' => function($response, $reason) use (&$fs) { + $_SESSION['original_request'] = $_SERVER['REQUEST_URI']; + header('Location: ' . $fs->oauthRedirectURL()); + exit; + } +]); +``` + +### Callback Reason Parameter + +The callback receives a `$reason` parameter: +- **`'expired'`**: Token expired due to 24-hour or 60-minute limit (re-authenticate) +- **`'invalid'`**: Token is invalid - may have been revoked or never valid + +## Approach 3: Enhanced Token Info Retrieval + +Access detailed token metadata for custom management logic. + +### Get Token Information + +```php +// Standard: getAccessToken() returns string +$token = $fs->getAccessToken(); + +// Enhanced: getAccessToken(true) returns array with metadata +$tokenInfo = $fs->getAccessToken(true); +/* +Array ( + [token] => abc123... + [created] => 1234567890 // Unix timestamp when token was created + [last_activity] => 1234567900 // Unix timestamp of last successful API call + [expires_at] => 1234571490 // Unix timestamp when token will expire + [is_expired] => false // Whether token is expired or expiring soon +) +*/ + +if ($tokenInfo['is_expired']) { + echo "Token is expired or expiring soon\n"; +} +``` + +### Manual Token Management + +```php +$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); + +// Set token without expiration info (treated as freshly issued) +$fs->setAccessToken('your-access-token-here'); + +// Set token with known remaining lifetime +$fs->setAccessToken('your-access-token-here', 3600); // 1 hour remaining +``` + +## Activity Tracking + +The SDK automatically tracks successful API calls (2xx, 3xx responses) to manage the 60-minute inactivity window. Each successful API call resets the inactivity timer, keeping the token alive for up to 24 hours of continued use. + + +## Configuration Options + +### Key Token Expiration Options + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + + // Token expiration + 'expirationWarningThreshold' => 300, // Seconds before expiration to warn (default: 5 min) + + // Authentication failure handling + 'onAuthenticationFailure' => function($response, $reason) { + // Your callback logic here + }, + 'replayFailedRequestsAfterAuth' => true, // Automatically retry after re-auth (default) +]); +``` + +## Request Replay + +When `replayFailedRequestsAfterAuth` is enabled (default), failed requests are automatically retried after successful re-authentication. The SDK retries once per request to prevent infinite loops. Check `$response->replayed` to see if a request was retried. + + +## Quick Reference + +```php +// Approach 1: Proactive Checking +if ($fs->isTokenExpired()) { + $fs->oauthPassword($username, $password); +} + +// Approach 2: Automatic Callback +$fs = new FamilySearch([ + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + if ($reason === 'expired') { + $fs->oauthPassword($username, $password); + } + } +]); + +// Approach 3: Enhanced Token Info +$tokenInfo = $fs->getAccessToken(true); +echo "Expires: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']); +``` + +## Additional Resources + +- [README.md](../README.md) - Getting started and basic usage +- [SECURITY.md](../SECURITY.md) - Session encryption and security best practices +- [FamilySearch API Documentation](https://developers.familysearch.org/) diff --git a/examples/.env.example b/examples/.env.example index 4cc0327..8f551b5 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -100,5 +100,5 @@ FS_ENCRYPTION_KEY=your_32_byte_encryption_key_here # - Use weak or predictable keys # # For comprehensive security guidance, see: -# https://github.com/FamilySearch/fs-php-lite/blob/master/SECURITY.md +# https://github.com/FamilySearch/fs-php-lite/blob/master/docs/SECURITY.md # diff --git a/examples/_includes.php b/examples/_includes.php index 2d06505..40fdd43 100644 --- a/examples/_includes.php +++ b/examples/_includes.php @@ -106,7 +106,7 @@ // Store in .env file: // FS_ENCRYPTION_KEY=WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= // -// See SECURITY.md for comprehensive security guidance. +// See docs/SECURITY.md for comprehensive security guidance. // ============================================================================= /** diff --git a/src/FamilySearch.php b/src/FamilySearch.php index 408169b..19377a9 100644 --- a/src/FamilySearch.php +++ b/src/FamilySearch.php @@ -9,12 +9,34 @@ class FamilySearch /** * SDK version number (Semantic Versioning) * + * Version 1.6.0: Added automatic request replay after re-authentication + * - New feature: Automatic retry of failed requests after successful re-authentication + * - Configuration option: replayFailedRequestsAfterAuth (default: true) + * - Transparent recovery: Failed request automatically retried with new token + * - Replay metadata: Response includes 'replayed' flag and 'originalResponse' + * - Single retry only: Prevents infinite loops + * - Backward compatible: Can be disabled, existing behavior preserved + * + * Version 1.5.0: Added authentication failure callback system + * - New feature: onAuthenticationFailure callback for handling 401 responses + * - Callback receives response object and reason ('expired' or 'invalid') + * - Allows automatic re-authentication on token expiration + * - Backward compatible: callback is optional, existing error handling preserved + * + * Version 1.4.0: Added token expiration tracking functionality + * - New feature: Client-side token expiration tracking (24hr absolute, 60min inactivity) + * - New methods: isTokenExpired(), getTokenExpirationTime(), setAccessToken() + * - Enhanced: getAccessToken() now supports detailed mode with expiration info + * - Session format: Now stores token with metadata (creation time, last activity) + * - Backward compatible: Existing sessions auto-migrate to new format + * - Activity tracking: Automatically updates last activity on successful API calls + * * Version 1.3.0: Added optional AES-256-GCM session token encryption * - New feature: sessionEncryption and sessionEncryptionKey configuration options * - Backward compatible: encryption defaults to disabled (no breaking changes) * - Security enhancement: protects OAuth tokens from filesystem disclosure */ - const VERSION = '1.3.0'; + const VERSION = '1.6.0'; /** * The FamilySearch reference or environment to target. Valid values are @@ -81,7 +103,7 @@ class FamilySearch * * @var bool * @see $sessionEncryptionKey for key requirements - * @see SECURITY.md for comprehensive security guidance + * @see docs/SECURITY.md for comprehensive security guidance */ private $sessionEncryption = false; @@ -126,7 +148,7 @@ class FamilySearch * @var string|null 32-byte encryption key (may be encoded as base64/hex) * @see $sessionEncryption to enable encryption * @see normalizeEncryptionKey() for key format handling - * @see SECURITY.md for key management best practices + * @see docs/SECURITY.md for key management best practices */ private $sessionEncryptionKey; @@ -136,10 +158,118 @@ class FamilySearch * @var string */ private $accessToken; - + + /** + * Unix timestamp when the access token was created/issued. + * Used to calculate absolute expiration (24 hours from creation). + * + * @var int|null + */ + private $tokenCreationTime; + + /** + * Unix timestamp of the last successful API call. + * Used to calculate inactivity expiration (60 minutes from last activity). + * Updated on every successful API response (statusCode < 400, not 401). + * + * @var int|null + */ + private $tokenLastActivityTime; + + /** + * Threshold in seconds before expiration to warn that token is expiring soon. + * Default: 300 seconds (5 minutes). + * + * When the token is within this threshold of expiration, applications can + * proactively refresh or re-authenticate before the token expires. + * + * @var int + */ + private $expirationWarningThreshold = 300; + + /** + * Callback function invoked when authentication fails (401 response). + * + * This callback allows applications to handle authentication failures gracefully + * by re-authenticating the user and obtaining a new access token. The callback + * is invoked before any exception handling, giving the application full control + * over the authentication flow. + * + * Callback Signature: + * function(object $response, string $reason): void + * + * Parameters: + * - $response: The HTTP response object with statusCode 401 + * - $reason: 'expired' if isTokenExpired() returns true, 'invalid' otherwise + * + * FamilySearch Token Characteristics: + * - No refresh tokens available - must re-authenticate to get new token + * - 401 responses don't distinguish between expired vs invalid tokens + * - SDK determines reason client-side using token expiration tracking + * + * Re-authentication Options in Callback: + * 1. Password grant: $this->oauthPassword($username, $password) + * 2. Authorization code: Redirect user to $this->oauthRedirectURL() + * 3. Unauthenticated session: Request new unauthenticated token + * 4. Custom storage: Load token from database/cache using $this->setAccessToken() + * + * Example Usage: + * ```php + * $fs = new FamilySearch([ + * 'onAuthenticationFailure' => function($response, $reason) use ($username, $password) { + * if ($reason === 'expired') { + * error_log('Token expired, re-authenticating...'); + * $this->oauthPassword($username, $password); + * } else { + * error_log('Authentication invalid, redirecting to login...'); + * header('Location: /login'); + * exit; + * } + * } + * ]); + * ``` + * + * @var callable|null + */ + private $onAuthenticationFailure; + + /** + * Whether to automatically replay failed requests after successful re-authentication. + * + * When enabled (default), if the onAuthenticationFailure callback obtains a new + * access token (by calling setAccessToken() or oauthPassword()), the SDK will + * automatically retry the original request that failed with 401. + * + * This provides transparent recovery from token expiration: + * 1. Original request fails with 401 + * 2. Callback re-authenticates and gets new token + * 3. SDK automatically retries the original request + * 4. User gets successful response without manual intervention + * + * Replay is performed only once to prevent infinite loops. If the retry also + * fails with 401, no further retries are attempted. + * + * Set to false to disable automatic replay and handle retries manually in the + * application code. + * + * @var bool + */ + private $replayFailedRequestsAfterAuth = true; + + /** + * Flag tracking whether current request is a retry after authentication failure. + * + * This prevents infinite retry loops by ensuring we only retry once. When a + * request is being retried after successful re-authentication, this flag is + * set to true. If the retry also fails with 401, we don't retry again. + * + * @var bool + */ + private $isRetryRequest = false; + /** * Maximum number of times to retry when being throttled - * + * * @var integer */ private $maxThrottledRetries = 5; @@ -183,6 +313,14 @@ class FamilySearch * Generate secure key: base64_encode(random_bytes(32)) * @param string $options['accessToken'] Manually provide access token (bypasses session storage) * @param int $options['maxThrottledRetries'] Maximum retry attempts for throttled requests (default: 5) + * @param int $options['expirationWarningThreshold'] Seconds before expiration to warn token is expiring (default: 300) + * @param callable $options['onAuthenticationFailure'] Callback invoked on 401 responses: function($response, $reason) + * - $reason is 'expired' or 'invalid' based on client-side tracking + * - Callback can re-authenticate and call setAccessToken() with new token + * @param bool $options['replayFailedRequestsAfterAuth'] Auto-retry failed requests after re-authentication (default: true) + * - If callback obtains new token, automatically retry the original request + * - Only retries once to prevent infinite loops + * - Set to false to disable automatic replay * @param array $options['pendingModifications'] Array of pending modification feature flags * @param string $options['userAgent'] Additional user agent string to append to default * @param bool $options['objects'] Enable gedcomx-php object serialization/deserialization (default: false) @@ -293,7 +431,20 @@ public function __construct($options = array()) try { $decrypted = $this->decryptToken($sessionValue); if ($decrypted !== false) { - $this->accessToken = $decrypted; + // Decrypt succeeded - extract token and metadata + $metadata = $this->deserializeTokenMetadata($decrypted); + if ($metadata !== false) { + $this->accessToken = $metadata['token']; + $this->tokenCreationTime = $metadata['created']; + $this->tokenLastActivityTime = $metadata['last_activity']; + + // Backward compatibility: Initialize timestamps if null (old session format) + if ($this->tokenCreationTime === null || $this->tokenLastActivityTime === null) { + $now = time(); + $this->tokenCreationTime = $this->tokenCreationTime ?? $now; + $this->tokenLastActivityTime = $this->tokenLastActivityTime ?? $now; + } + } } else { // SECURITY FAILURE: Decryption returned false // Possible causes: @@ -344,6 +495,22 @@ public function __construct($options = array()) // ============================================================= // SCENARIO 3 & 4: Token appears to be plaintext // ============================================================= + // Extract token and metadata from plaintext session value + $metadata = $this->deserializeTokenMetadata($sessionValue); + if ($metadata !== false) { + $this->accessToken = $metadata['token']; + $this->tokenCreationTime = $metadata['created']; + $this->tokenLastActivityTime = $metadata['last_activity']; + + // Backward compatibility: Initialize timestamps if null (old session format) + if ($this->tokenCreationTime === null || $this->tokenLastActivityTime === null) { + $now = time(); + $this->tokenCreationTime = $this->tokenCreationTime ?? $now; + $this->tokenLastActivityTime = $this->tokenLastActivityTime ?? $now; + } + } + + // Apply encryption/no-encryption policy if ($this->sessionEncryption) { // SCENARIO 3: Encryption enabled + plaintext token (MIGRATION) // User enabled encryption but session contains plaintext token @@ -352,12 +519,12 @@ public function __construct($options = array()) // MIGRATION STRATEGY: Accept plaintext token temporarily // On next OAuth response, token will be encrypted automatically // This provides zero-downtime migration without forcing logout - $this->accessToken = $sessionValue; + // (Token already loaded above) } else { // SCENARIO 4: Encryption disabled + plaintext token (LEGACY) // This is normal legacy operation before encryption was enabled // Token is stored in plaintext in session (not recommended for production) - $this->accessToken = $sessionValue; + // (Token already loaded above) } } } @@ -365,11 +532,29 @@ public function __construct($options = array()) if (isset($options['accessToken'])) { $this->accessToken = $options['accessToken']; } - + if (isset($options['maxThrottledRetries'])) { $this->maxThrottledRetries = $options['maxThrottledRetries']; } - + + if (isset($options['expirationWarningThreshold']) && is_int($options['expirationWarningThreshold'])) { + $this->expirationWarningThreshold = $options['expirationWarningThreshold']; + } + + if (isset($options['onAuthenticationFailure'])) { + if (is_callable($options['onAuthenticationFailure'])) { + $this->onAuthenticationFailure = $options['onAuthenticationFailure']; + } else { + throw new \InvalidArgumentException( + 'onAuthenticationFailure must be a callable (function, closure, or array with object and method)' + ); + } + } + + if (isset($options['replayFailedRequestsAfterAuth']) && is_bool($options['replayFailedRequestsAfterAuth'])) { + $this->replayFailedRequestsAfterAuth = $options['replayFailedRequestsAfterAuth']; + } + if (isset($options['objects']) && is_bool($options['objects'])) { $this->objects = $options['objects']; } @@ -465,21 +650,40 @@ private function oauthResponseHandler($response){ // Extract and store access token in memory (always available for current request) $this->accessToken = $response->data['access_token']; + // ================================================================ + // Initialize Token Timestamps for Expiration Tracking + // ================================================================ + // FamilySearch tokens expire after: + // - 24 hours from creation (absolute expiration), OR + // - 60 minutes of inactivity (inactivity expiration) + // Since FamilySearch OAuth response doesn't include expires_in, + // we track these timestamps client-side + $now = time(); + $this->tokenCreationTime = $now; + $this->tokenLastActivityTime = $now; + // ================================================================ // Session Token Storage with Optional Encryption // ================================================================ if ($this->sessions) { + // Serialize token with metadata (creation time, last activity) + $tokenMetadata = $this->serializeTokenMetadata( + $this->accessToken, + $this->tokenCreationTime, + $this->tokenLastActivityTime + ); + if ($this->sessionEncryption) { // ======================================================== // SECURE PATH: Encryption enabled - encrypt before storing // ======================================================== try { - // Encrypt the access token using AES-256-GCM authenticated encryption + // Encrypt the token metadata using AES-256-GCM authenticated encryption // Format: base64(iv):base64(tag):base64(ciphertext) // - IV (12 bytes): Unique random initialization vector // - Tag (16 bytes): Authentication tag for tamper detection - // - Ciphertext: Encrypted token data - $encryptedToken = $this->encryptToken($this->accessToken); + // - Ciphertext: Encrypted token data with metadata + $encryptedToken = $this->encryptToken($tokenMetadata); $_SESSION[$this->sessionVariable] = $encryptedToken; } catch (\Exception $e) { // ==================================================== @@ -508,7 +712,7 @@ private function oauthResponseHandler($response){ // ======================================================== // This is the legacy behavior before encryption was implemented // NOT RECOMMENDED for production environments - $_SESSION[$this->sessionVariable] = $this->accessToken; + $_SESSION[$this->sessionVariable] = $tokenMetadata; } } } @@ -516,14 +720,206 @@ private function oauthResponseHandler($response){ } /** - * Get the access token, if it exists. - * - * @return string access token + * Get the access token, optionally with detailed expiration information. + * + * Backward Compatibility: + * - When called without parameters: Returns token string (backward compatible) + * - When called with $detailed = true: Returns array with token and expiration info + * + * Detailed Format (when $detailed = true): + * [ + * 'token' => string, // OAuth access token + * 'created' => int|null, // Unix timestamp when token was created + * 'last_activity' => int|null, // Unix timestamp of last successful API call + * 'expires_at' => int|null, // Unix timestamp when token will expire + * 'is_expired' => bool // Whether token is expired or expiring soon + * ] + * + * @param bool $detailed If true, return array with expiration info; if false, return token string + * @return string|array|null Token string, detailed array, or null if no token */ - public function getAccessToken() + public function getAccessToken($detailed = false) { + if ($detailed) { + // Return detailed information + return [ + 'token' => $this->accessToken, + 'created' => $this->tokenCreationTime, + 'last_activity' => $this->tokenLastActivityTime, + 'expires_at' => $this->getTokenExpirationTime(), + 'is_expired' => $this->isTokenExpired() + ]; + } + + // Backward compatible: return just the token string return $this->accessToken; } + + /** + * Check if the access token is expired or expiring soon. + * + * FamilySearch access tokens expire under two conditions (whichever comes first): + * 1. Absolute expiration: 24 hours after token creation + * 2. Inactivity expiration: 60 minutes since last successful API call + * + * This method returns true if: + * - No token is present + * - Token has expired (either condition met) + * - Token will expire within the warning threshold (default: 5 minutes) + * + * Token Expiration Rules (FamilySearch Platform): + * - Maximum lifetime: 24 hours from issuance + * - Inactivity timeout: 60 minutes since last API call + * - Each successful API call resets the 60-minute inactivity timer + * - No refresh tokens available - must re-authenticate when expired + * + * Warning Threshold: + * By default, returns true when token is within 5 minutes of expiration. + * This allows applications to proactively re-authenticate before token expires. + * Configure via 'expirationWarningThreshold' option (in seconds). + * + * @return bool True if token is expired or expiring soon, false otherwise + */ + public function isTokenExpired() + { + // No token present + if (empty($this->accessToken)) { + return true; + } + + // No timestamp information (shouldn't happen with new code, but handle gracefully) + if ($this->tokenCreationTime === null || $this->tokenLastActivityTime === null) { + return false; // Assume valid if we have a token but no timestamp data + } + + $now = time(); + $expirationTime = $this->getTokenExpirationTime(); + + // Token is expired or within warning threshold + return ($now + $this->expirationWarningThreshold) >= $expirationTime; + } + + /** + * Get the Unix timestamp when the access token will expire. + * + * FamilySearch tokens expire based on the EARLIER of: + * 1. Absolute expiration: 24 hours (86400 seconds) from token creation + * 2. Inactivity expiration: 60 minutes (3600 seconds) from last activity + * + * This method calculates both expiration times and returns the sooner one, + * which represents when the token will actually expire. + * + * Token Lifecycle: + * - Created at timestamp T + * - Absolute expiration: T + 86400 seconds + * - Last activity at timestamp A + * - Inactivity expiration: A + 3600 seconds + * - Actual expiration: min(T + 86400, A + 3600) + * + * Returns null if: + * - No token is present + * - Token timestamp information is not available + * + * @return int|null Unix timestamp when token expires, or null if no token/timestamp data + */ + public function getTokenExpirationTime() + { + // No token present + if (empty($this->accessToken)) { + return null; + } + + // No timestamp information + if ($this->tokenCreationTime === null || $this->tokenLastActivityTime === null) { + return null; + } + + // Calculate both expiration conditions + $absoluteExpiration = $this->tokenCreationTime + 86400; // 24 hours from creation + $inactivityExpiration = $this->tokenLastActivityTime + 3600; // 60 minutes from last activity + + // Return the sooner of the two (whichever comes first) + return min($absoluteExpiration, $inactivityExpiration); + } + + /** + * Set the access token manually with optional expiration time. + * + * This method allows applications to manually set an access token, which is useful for: + * - Restoring tokens from custom storage (database, Redis, etc.) + * - Testing and development + * - Custom authentication flows + * - Re-authentication in onAuthenticationFailure callback + * + * Token Expiration Behavior: + * - If $expiresIn is null (default): Token is treated as freshly issued + * - Creation time set to now + * - Last activity set to now + * - Token will expire in 24 hours or after 60 minutes of inactivity + * + * - If $expiresIn is provided: Used for backward compatibility + * - Creation time calculated as: now - (86400 - $expiresIn) + * - Last activity set to now + * - Allows restoring tokens with remaining lifetime + * + * The token is stored in memory and optionally persisted to session storage + * (with encryption if enabled). + * + * Automatic Request Replay: + * If this method is called during an onAuthenticationFailure callback, it signals + * that the callback successfully obtained a new token. If replayFailedRequestsAfterAuth + * is enabled (default), the SDK will automatically retry the original failed request + * with the new token. + * + * @param string $token OAuth access token to set + * @param int|null $expiresIn Optional: seconds until token expires (null = treat as new token) + * @return void + */ + public function setAccessToken($token, $expiresIn = null) + { + $this->accessToken = $token; + $now = time(); + + if ($expiresIn === null) { + // Treat as freshly issued token + $this->tokenCreationTime = $now; + $this->tokenLastActivityTime = $now; + } else { + // Calculate creation time based on remaining lifetime + // If token has X seconds left, it was created (24 hours - X seconds) ago + $this->tokenCreationTime = $now - (86400 - $expiresIn); + $this->tokenLastActivityTime = $now; + } + + // Store in session if sessions are enabled + if ($this->sessions) { + // Serialize token with metadata + $tokenMetadata = $this->serializeTokenMetadata( + $this->accessToken, + $this->tokenCreationTime, + $this->tokenLastActivityTime + ); + + if ($this->sessionEncryption) { + // Encrypt before storing + try { + $encryptedToken = $this->encryptToken($tokenMetadata); + $_SESSION[$this->sessionVariable] = $encryptedToken; + } catch (\Exception $e) { + // Encryption failed - clear session + unset($_SESSION[$this->sessionVariable]); + trigger_error( + 'Failed to encrypt session token: ' . $e->getMessage() . '. ' . + 'Token not stored in session (available for current request only).', + E_USER_WARNING + ); + } + } else { + // Store plaintext + $_SESSION[$this->sessionVariable] = $tokenMetadata; + } + } + } /** * Check whether the client has an active session. This first checks for the @@ -756,20 +1152,20 @@ private function request($url, $options = array()) if (isset($response->headers['content-type']) && strpos($response->headers['content-type'], 'json') !== false) { try { $response->data = json_decode($response->body, true); - + // Instantiate objects via gedcomx-php when configured if ($response->data && $this->objects){ - + // Atom Feed if (isset($response->data['entries'])){ $response->gedcomx = new \Gedcomx\Atom\Feed($response->data); - } - + } + // OAuth token success response else if (isset($response->data['access_token']) || isset($response->data['error'])) { $response->gedcomx = new \Gedcomx\Extensions\FamilySearch\OAuth2($response->data); - } - + } + // GedcomX else { $response->gedcomx = new \Gedcomx\Extensions\FamilySearch\FamilySearchPlatform($response->data); @@ -777,7 +1173,126 @@ private function request($url, $options = array()) } } catch (Exception $e) { } } - + + // ================================================================ + // Authentication Failure Callback (401 Responses) + // ================================================================ + // FamilySearch returns 401 when: + // - Token has expired (24 hours OR 60 minutes of inactivity) + // - Token is invalid or revoked + // - No token was provided for a protected endpoint + // + // The API does not distinguish between these cases in the response, + // so we use client-side token tracking to determine the likely reason. + // + // If onAuthenticationFailure callback is configured, invoke it before + // returning the response. This allows applications to: + // - Re-authenticate automatically (password grant) + // - Redirect user to login page (authorization code flow) + // - Load token from alternative storage + // - Log authentication failures for monitoring + // + // The callback receives: + // - $response: Full response object with statusCode 401 + // - $reason: 'expired' if token tracking indicates expiration, 'invalid' otherwise + // + // After callback execution, if the callback obtained a new token (by calling + // setAccessToken() or oauthPassword()) and automatic replay is enabled, the SDK + // will retry the original request once with the new token. + if ($response->statusCode === 401 && $this->onAuthenticationFailure !== null && !$this->isRetryRequest) { + // Determine failure reason based on client-side token tracking + // Note: FamilySearch API doesn't distinguish, so this is our best guess + $reason = $this->isTokenExpired() ? 'expired' : 'invalid'; + + // ============================================================ + // Invoke Authentication Failure Callback + // ============================================================ + // Capture the token before callback execution + $tokenBeforeCallback = $this->accessToken; + + try { + // Invoke the callback with response and reason + // Callback may: + // - Call oauthPassword() to re-authenticate + // - Call setAccessToken() with a cached/stored token + // - Redirect user to login page + // - Log the failure for monitoring + call_user_func($this->onAuthenticationFailure, $response, $reason); + } catch (\Exception $e) { + // Callback threw exception - log it but don't break request flow + // This ensures SDK remains stable even if callback has bugs + trigger_error( + 'onAuthenticationFailure callback threw exception: ' . $e->getMessage(), + E_USER_WARNING + ); + } + + // Check if token changed during callback execution + // This works for both setAccessToken() and oauthPassword() methods + $tokenChanged = ($this->accessToken !== $tokenBeforeCallback && $this->accessToken !== null); + + // ============================================================ + // Automatic Request Replay After Successful Re-authentication + // ============================================================ + // If the callback obtained a new token and replay is enabled, + // automatically retry the original request with the new token. + // + // Conditions for replay: + // 1. Token changed during callback (different from before) + // 2. Automatic replay is enabled (replayFailedRequestsAfterAuth = true) + // 3. This is not already a retry (prevents infinite loops) + // + // Benefits: + // - Transparent recovery from token expiration + // - User gets successful response without manual intervention + // - Application doesn't need to handle retries manually + if ($tokenChanged && $this->replayFailedRequestsAfterAuth) { + // Mark this as a retry request to prevent infinite loops + $this->isRetryRequest = true; + + try { + // Update the Authorization header with the new token + // The options may still have the old token in the Authorization header + // from the original request, so we need to update it + if ($this->getAccessToken()) { + $options['headers']['Authorization'] = 'Bearer ' . $this->getAccessToken(); + } + + // Retry the original request with the new token + // The new token is already set in $this->accessToken and has been + // updated in the Authorization header above + $replayResponse = $this->request($url, $options); + + // Mark that this response came from a replay + $replayResponse->replayed = true; + $replayResponse->originalResponse = $response; + + // Return the replay response instead of the original 401 + return $replayResponse; + } finally { + // Always reset the retry flag after replay attempt + $this->isRetryRequest = false; + } + } + } + + // ================================================================ + // Update Last Activity Timestamp for Token Expiration Tracking + // ================================================================ + // FamilySearch tokens expire after 60 minutes of inactivity. + // Each successful API call resets this 60-minute timer. + // Update last activity timestamp only for successful responses. + // + // Success criteria: + // - Status code < 400 (2xx or 3xx responses) + // - Status code is not 401 (not an authentication failure) + // + // Note: 401 responses indicate expired or invalid token, so we + // should NOT update last activity in that case. + if ($response->statusCode < 400 && $response->statusCode !== 401) { + $this->updateLastActivity(); + } + return $response; } else { throw new Exception(curl_errno($request).' - '.curl_error($request)); @@ -1314,4 +1829,134 @@ private function decryptToken($encryptedData) return $plaintext; } + /** + * Update the last activity timestamp for token expiration tracking. + * + * This method is called after every successful API request to reset the + * 60-minute inactivity timer. FamilySearch tokens expire after 60 minutes + * of inactivity, so updating this timestamp on each API call keeps the + * token alive (up to the 24-hour absolute maximum). + * + * Updates: + * - In-memory last activity timestamp + * - Session storage (with encryption if enabled) + * + * This method only updates if: + * - A token is present + * - Token has timestamp information + * - Sessions are enabled + * + * @return void + */ + private function updateLastActivity() + { + // Only update if we have a token with timestamp information + if (empty($this->accessToken) || $this->tokenCreationTime === null) { + return; + } + + // Update in-memory timestamp + $this->tokenLastActivityTime = time(); + + // Persist to session if sessions are enabled + if ($this->sessions) { + // Serialize token with updated metadata + $tokenMetadata = $this->serializeTokenMetadata( + $this->accessToken, + $this->tokenCreationTime, + $this->tokenLastActivityTime + ); + + if ($this->sessionEncryption) { + // Encrypt before storing + try { + $encryptedToken = $this->encryptToken($tokenMetadata); + $_SESSION[$this->sessionVariable] = $encryptedToken; + } catch (\Exception $e) { + // Encryption failed - log warning but don't clear session + // Token remains valid in memory for current request + trigger_error( + 'Failed to update encrypted session token: ' . $e->getMessage(), + E_USER_WARNING + ); + } + } else { + // Store plaintext + $_SESSION[$this->sessionVariable] = $tokenMetadata; + } + } + } + + /** + * Serialize token metadata for session storage. + * + * Creates a JSON-encoded string containing the token and its metadata (creation time + * and last activity timestamp). This format allows us to track token expiration while + * maintaining all token information in a single session variable. + * + * Format: JSON object with keys: + * - token: The OAuth access token string + * - created: Unix timestamp when token was issued + * - last_activity: Unix timestamp of last successful API call + * + * @param string $token OAuth access token + * @param int $createdTime Unix timestamp when token was created + * @param int $lastActivityTime Unix timestamp of last successful API call + * @return string JSON-encoded token metadata + */ + private function serializeTokenMetadata($token, $createdTime, $lastActivityTime) + { + return json_encode([ + 'token' => $token, + 'created' => $createdTime, + 'last_activity' => $lastActivityTime + ]); + } + + /** + * Deserialize token metadata from session storage. + * + * Parses the JSON-encoded token metadata stored in the session. This method handles + * backward compatibility by detecting whether the session value is: + * 1. New format: JSON object with token metadata + * 2. Old format: Plain token string (for backward compatibility) + * + * Backward Compatibility: + * - If session contains plain token string, returns array with token and null timestamps + * - Caller should handle null timestamps by initializing them to current time + * + * @param string $serialized Session value (JSON metadata or plain token string) + * @return array|false Array with keys [token, created, last_activity] or false on failure + * - token: OAuth access token string + * - created: Unix timestamp or null (for old format) + * - last_activity: Unix timestamp or null (for old format) + */ + private function deserializeTokenMetadata($serialized) + { + // Attempt to decode as JSON (new format) + $data = json_decode($serialized, true); + + if (is_array($data) && isset($data['token'])) { + // New format with metadata + return [ + 'token' => $data['token'], + 'created' => $data['created'] ?? null, + 'last_activity' => $data['last_activity'] ?? null + ]; + } + + // Old format: plain token string (backward compatibility) + // Return with null timestamps - caller should initialize them + if (is_string($serialized) && !empty($serialized)) { + return [ + 'token' => $serialized, + 'created' => null, + 'last_activity' => null + ]; + } + + // Invalid format + return false; + } + } \ No newline at end of file diff --git a/tests/Integration/AuthenticationCallbackTest.php b/tests/Integration/AuthenticationCallbackTest.php new file mode 100644 index 0000000..07cad57 --- /dev/null +++ b/tests/Integration/AuthenticationCallbackTest.php @@ -0,0 +1,260 @@ +getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token-will-get-401', + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$receivedReason, &$receivedStatusCode) { + $callbackInvoked = true; + $receivedReason = $reason; + $receivedStatusCode = $response->statusCode; + } + ]); + + // Make request with invalid token - should get 401 + $response = $client->get('/platform/users/current'); + + // Callback should have been invoked + $this->assertTrue($callbackInvoked, 'Callback should be invoked on 401 response'); + $this->assertEquals(401, $receivedStatusCode, 'Status code should be 401'); + $this->assertEquals('invalid', $receivedReason, 'Reason should be invalid (token not expired, just invalid)'); + + // Response should still be returned normally + $this->assertEquals(401, $response->statusCode); + } + + /** + * Test that callback receives 'expired' reason for expired token + */ + public function testCallbackReceivesExpiredReasonForExpiredToken(): void + { + $callbackInvoked = false; + $receivedReason = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'expirationWarningThreshold' => 0, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$receivedReason) { + $callbackInvoked = true; + $receivedReason = $reason; + } + ]); + + // Set token that appears expired (created long ago) + $client->setAccessToken('expired-looking-token', -3600); // Token expired 1 hour ago + + // Make request - should get 401 + $response = $client->get('/platform/users/current'); + + // Callback should have been invoked with 'expired' reason + $this->assertTrue($callbackInvoked, 'Callback should be invoked on 401 response'); + $this->assertEquals('expired', $receivedReason, 'Reason should be expired (based on client-side tracking)'); + $this->assertEquals(401, $response->statusCode); + } + + /** + * Test that callback can re-authenticate and retry request + */ + public function testCallbackCanReauthenticate(): void + { + $callbackInvoked = false; + $reauthenticated = false; + + $creds = $this->getCredentials(); + + // Create client with callback that re-authenticates + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$reauthenticated, $creds, &$client) { + $callbackInvoked = true; + + // Re-authenticate using password grant + $authResponse = $client->oauthPassword($creds['username'], $creds['password']); + + if ($authResponse->statusCode === 200) { + $reauthenticated = true; + } + } + ]); + + // First request with invalid token - should trigger callback and re-auth + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked on first 401'); + $this->assertTrue($reauthenticated, 'Should have re-authenticated in callback'); + + // Token should now be valid + $this->assertNotNull($client->getAccessToken()); + $this->assertNotEquals('invalid-initial-token', $client->getAccessToken()); + + // Second request should succeed with new token + $response2 = $client->get('/platform/users/current'); + $this->assertLessThan(400, $response2->statusCode, 'Second request should succeed with new token'); + } + + /** + * Test that SDK continues to work without callback (backward compatibility) + */ + public function testBackwardCompatibilityWithout401Callback(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token' + ]); + + // Make request with invalid token - should get 401, no callback invoked + $response = $client->get('/platform/users/current'); + + // Should get 401 response normally (no callback, no exception) + $this->assertEquals(401, $response->statusCode); + } + + /** + * Test that callback exception doesn't break request flow + */ + public function testCallbackExceptionDoesntBreakFlow(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + // Throw exception in callback + throw new \Exception('Callback error'); + } + ]); + + // Suppress PHP warnings from the callback exception handler + $errorLevel = error_reporting(); + error_reporting($errorLevel & ~E_USER_WARNING); + + // Make request - callback will throw, but request should complete + $response = $client->get('/platform/users/current'); + + // Restore error reporting + error_reporting($errorLevel); + + $this->assertTrue($callbackInvoked, 'Callback should have been invoked'); + $this->assertEquals(401, $response->statusCode, 'Should still return 401 response despite callback exception'); + } + + /** + * Test callback with multiple 401 responses + */ + public function testCallbackInvokedMultipleTimes(): void + { + $invocationCount = 0; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token-first', + 'onAuthenticationFailure' => function($response, $reason) use (&$invocationCount) { + $invocationCount++; + } + ]); + + // Make first request with invalid token + $response1 = $client->get('/platform/users/current'); + + // Reset token to a different invalid value for second request + $client->setAccessToken('invalid-token-second'); + + // Make second request with different invalid token + $response2 = $client->get('/platform/users/current'); + + // At least one of these should have triggered the callback + // (The API behavior with invalid tokens can vary) + $this->assertGreaterThanOrEqual(1, $invocationCount, 'Callback should be invoked at least once for 401 responses'); + } + + /** + * Test that successful requests don't trigger callback + */ + public function testCallbackNotInvokedOnSuccessfulRequest(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + } + ]); + + // Authenticate with valid credentials + $authResponse = $client->oauthPassword($creds['username'], $creds['password']); + $this->assertResponseOK($authResponse); + + // Make successful request + $response = $client->get('/platform/users/current'); + $this->assertResponseOK($response); + + // Callback should NOT be invoked for successful requests + $this->assertFalse($callbackInvoked, 'Callback should not be invoked on successful (non-401) requests'); + } + + /** + * Test callback receives full response object + */ + public function testCallbackReceivesFullResponseObject(): void + { + $receivedResponse = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'onAuthenticationFailure' => function($response, $reason) use (&$receivedResponse) { + $receivedResponse = $response; + } + ]); + + // Make request that will get 401 + $response = $client->get('/platform/users/current'); + + // Callback should have received full response object + $this->assertNotNull($receivedResponse); + $this->assertIsObject($receivedResponse); + $this->assertObjectHasProperty('statusCode', $receivedResponse); + $this->assertObjectHasProperty('headers', $receivedResponse); + $this->assertObjectHasProperty('body', $receivedResponse); + $this->assertEquals(401, $receivedResponse->statusCode); + } +} diff --git a/tests/Integration/EncryptedSessionFlowTest.php b/tests/Integration/EncryptedSessionFlowTest.php index 3ac61e7..d8ee871 100644 --- a/tests/Integration/EncryptedSessionFlowTest.php +++ b/tests/Integration/EncryptedSessionFlowTest.php @@ -125,9 +125,12 @@ public function testOAuthFlowWithEncryptionDisabled(): void // Verify response $this->assertEquals(200, $response->statusCode); - // Verify token is stored in session as plaintext + // Verify token is stored in session as unencrypted JSON with metadata $this->assertArrayHasKey('FS_ACCESS_TOKEN', $_SESSION); - $this->assertEquals($this->testToken, $_SESSION['FS_ACCESS_TOKEN'], 'Token should be stored as plaintext'); + $sessionData = json_decode($_SESSION['FS_ACCESS_TOKEN'], true); + $this->assertIsArray($sessionData, 'Session should contain JSON metadata'); + $this->assertArrayHasKey('token', $sessionData); + $this->assertEquals($this->testToken, $sessionData['token'], 'Token should be stored in metadata'); } public function testTokenPersistsAcrossRequestsWhenPlaintext(): void @@ -354,9 +357,12 @@ public function testPlaintextTokenWorksWithCustomSessionVariable(): void $this->simulateOAuthResponse($fs, $this->testToken); - // Verify token is stored in custom variable as plaintext + // Verify token is stored in custom variable as plaintext (with metadata) $this->assertArrayHasKey($customVar, $_SESSION); - $this->assertEquals($this->testToken, $_SESSION[$customVar], 'Token should be plaintext in custom variable'); + $sessionData = json_decode($_SESSION[$customVar], true); + $this->assertIsArray($sessionData, 'Session should contain JSON metadata'); + $this->assertArrayHasKey('token', $sessionData); + $this->assertEquals($this->testToken, $sessionData['token'], 'Token should be stored in metadata'); // Clean up unset($_SESSION[$customVar]); diff --git a/tests/Integration/RequestReplayTest.php b/tests/Integration/RequestReplayTest.php new file mode 100644 index 0000000..0d9ceb9 --- /dev/null +++ b/tests/Integration/RequestReplayTest.php @@ -0,0 +1,339 @@ +getCredentials(); + + // Create client with callback that re-authenticates + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'replayFailedRequestsAfterAuth' => true, // Enable automatic replay + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$reauthenticated, $creds, &$clientRef) { + $callbackInvoked = true; + + // Re-authenticate using password grant + $authResponse = $clientRef->oauthPassword($creds['username'], $creds['password']); + + if ($authResponse->statusCode === 200) { + $reauthenticated = true; + } + } + ]); + + // Store reference for callback + $clientRef = $client; + + // Make request with invalid token + // Should fail with 401, callback re-authenticates, request is automatically retried + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked on 401'); + $this->assertTrue($reauthenticated, 'Should have re-authenticated in callback'); + + // Response should be successful (from replay, not original 401) + $this->assertLessThan(400, $response->statusCode, 'Replayed request should succeed'); + + // Response should have replay metadata + $this->assertObjectHasProperty('replayed', $response); + $this->assertTrue($response->replayed, 'Response should be marked as replayed'); + $this->assertObjectHasProperty('originalResponse', $response); + $this->assertEquals(401, $response->originalResponse->statusCode, 'Original response should be 401'); + } + + /** + * Test that replay can be disabled + */ + public function testReplayCanBeDisabled(): void + { + $callbackInvoked = false; + $reauthenticated = false; + $clientRef = null; + + $creds = $this->getCredentials(); + + // Create client with callback that re-authenticates but replay disabled + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'replayFailedRequestsAfterAuth' => false, // Disable automatic replay + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$reauthenticated, $creds, &$clientRef) { + $callbackInvoked = true; + + // Re-authenticate using password grant + $authResponse = $clientRef->oauthPassword($creds['username'], $creds['password']); + + if ($authResponse->statusCode === 200) { + $reauthenticated = true; + } + } + ]); + + // Store reference + $clientRef = $client; + + // Make request with invalid token + // Should fail with 401, callback re-authenticates, but NO automatic retry + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked on 401'); + $this->assertTrue($reauthenticated, 'Should have re-authenticated in callback'); + + // Response should still be 401 (no replay) + $this->assertEquals(401, $response->statusCode, 'Should return original 401 when replay disabled'); + + // Response should NOT have replay metadata + $this->assertObjectNotHasProperty('replayed', $response); + + // But token should be valid now, so next request should succeed + $response2 = $client->get('/platform/users/current'); + $this->assertLessThan(400, $response2->statusCode, 'Next request should succeed with new token'); + } + + /** + * Test that replay only happens once (no infinite loops) + */ + public function testReplayOnlyHappensOnce(): void + { + $callbackInvocationCount = 0; + $clientRef = null; + + $creds = $this->getCredentials(); + + // Create client with callback that sets an invalid token + // This will cause the replay to also fail with 401 + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvocationCount, &$clientRef) { + $callbackInvocationCount++; + + // Set another invalid token (replay will also fail) + $clientRef->setAccessToken('still-invalid-token-' . $callbackInvocationCount); + } + ]); + + // Store reference + $clientRef = $client; + + // Make request - should trigger callback, replay, and callback again, but then stop + $response = $client->get('/platform/users/current'); + + // Callback should be invoked twice: once for original, once for replay + // But no infinite loop - should stop after second failure + $this->assertLessThanOrEqual(2, $callbackInvocationCount, 'Should not retry infinitely'); + + // Final response should still be 401 + $this->assertEquals(401, $response->statusCode); + } + + /** + * Test that replay doesn't happen if callback doesn't change token + */ + public function testNoReplayIfTokenNotChanged(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + // Callback does nothing - doesn't obtain new token + } + ]); + + // Make request with invalid token + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked'); + + // Response should be 401 (no replay because token didn't change) + $this->assertEquals(401, $response->statusCode); + + // Response should NOT have replay metadata + $this->assertObjectNotHasProperty('replayed', $response); + } + + /** + * Test replay with successful retry + */ + public function testReplayWithSuccessfulRetry(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + + // First authenticate to get a valid token + $tempClient = new \FamilySearch(['appKey' => $creds['api_key'], 'sessions' => false]); + $authResponse = $tempClient->oauthPassword($creds['username'], $creds['password']); + $this->assertResponseOK($authResponse); + $validToken = $tempClient->getAccessToken(); + + $clientRef = null; + + // Create client with invalid token and callback that sets valid token + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$clientRef, $validToken) { + $callbackInvoked = true; + // Set the valid token obtained earlier + $clientRef->setAccessToken($validToken); + } + ]); + + // Store reference + $clientRef = $client; + + // Make request - should fail, callback sets valid token, replay succeeds + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked'); + $this->assertLessThan(400, $response->statusCode, 'Replayed request should succeed'); + $this->assertTrue($response->replayed ?? false, 'Response should be marked as replayed'); + } + + /** + * Test replay with POST request + */ + public function testReplayWithPostRequest(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + + // First authenticate to get a valid token + $tempClient = new \FamilySearch(['appKey' => $creds['api_key'], 'sessions' => false]); + $authResponse = $tempClient->oauthPassword($creds['username'], $creds['password']); + $this->assertResponseOK($authResponse); + $validToken = $tempClient->getAccessToken(); + + $clientRef = null; + + // Create client with invalid token + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-initial-token', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$clientRef, $validToken) { + $callbackInvoked = true; + $clientRef->setAccessToken($validToken); + } + ]); + + // Store reference + $clientRef = $client; + + // Make POST request - should fail, callback sets valid token, replay succeeds + $response = $client->post('/platform/tree/persons', [ + 'body' => $this->personData() + ]); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked'); + // POST should succeed on replay + $this->assertLessThan(400, $response->statusCode, 'Replayed POST should succeed'); + } + + /** + * Test that successful requests don't trigger replay logic + */ + public function testSuccessfulRequestsNoReplay(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + } + ]); + + // Authenticate with valid credentials + $authResponse = $client->oauthPassword($creds['username'], $creds['password']); + $this->assertResponseOK($authResponse); + + // Make successful request + $response = $client->get('/platform/users/current'); + $this->assertResponseOK($response); + + // Callback should NOT be invoked + $this->assertFalse($callbackInvoked, 'Callback should not be invoked on successful requests'); + + // Response should NOT have replay metadata + $this->assertObjectNotHasProperty('replayed', $response); + } + + /** + * Test replay with expired token reason + */ + public function testReplayWithExpiredTokenReason(): void + { + $receivedReason = null; + $callbackInvoked = false; + $clientRef = null; + + $creds = $this->getCredentials(); + + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'expirationWarningThreshold' => 0, + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$receivedReason, $creds, &$clientRef) { + $callbackInvoked = true; + $receivedReason = $reason; + + // Re-authenticate + $clientRef->oauthPassword($creds['username'], $creds['password']); + } + ]); + + // Store reference + $clientRef = $client; + + // Set expired token + $client->setAccessToken('expired-token', -3600); + + // Make request + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked); + $this->assertEquals('expired', $receivedReason, 'Reason should be expired'); + $this->assertLessThan(400, $response->statusCode, 'Replayed request should succeed'); + } +} diff --git a/tests/Integration/TokenExpirationComprehensiveTest.php b/tests/Integration/TokenExpirationComprehensiveTest.php new file mode 100644 index 0000000..da6b596 --- /dev/null +++ b/tests/Integration/TokenExpirationComprehensiveTest.php @@ -0,0 +1,669 @@ + false]); + + $beforeTime = time(); + $client->setAccessToken('test-token'); + $afterTime = time(); + + $details = $client->getAccessToken(true); + + $this->assertNotNull($details['created'], 'Creation timestamp should be recorded'); + $this->assertGreaterThanOrEqual($beforeTime, $details['created']); + $this->assertLessThanOrEqual($afterTime, $details['created']); + } + + /** + * Test 2: Last activity timestamp is updated on successful API calls + */ + public function testLastActivityUpdatedOnSuccessfulCalls(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false + ]); + + // Authenticate + $authResponse = $client->oauthPassword($creds['username'], $creds['password']); + $this->assertResponseOK($authResponse); + + // Get initial timestamps + $details1 = $client->getAccessToken(true); + $initialActivity = $details1['last_activity']; + + // Wait a moment to ensure timestamp difference + sleep(1); + + // Make successful API call + $response = $client->get('/platform/users/current'); + $this->assertResponseOK($response); + + // Check that last activity was updated + $details2 = $client->getAccessToken(true); + $this->assertGreaterThan($initialActivity, $details2['last_activity'], + 'Last activity should be updated after successful API call'); + } + + /** + * Test 3: Last activity timestamp is NOT updated on 401 or error responses + */ + public function testLastActivityNotUpdatedOn401(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token-will-get-401' + ]); + + // Get initial timestamps + $details1 = $client->getAccessToken(true); + $initialActivity = $details1['last_activity']; + + // Wait a moment + sleep(1); + + // Make request that will get 401 + $response = $client->get('/platform/users/current'); + $this->assertEquals(401, $response->statusCode); + + // Check that last activity was NOT updated + $details2 = $client->getAccessToken(true); + $this->assertEquals($initialActivity, $details2['last_activity'], + 'Last activity should NOT be updated on 401 response'); + } + + /** + * Test 4: isTokenExpired() returns false for fresh tokens + */ + public function testFreshTokenNotExpired(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 // No warning threshold + ]); + + $client->setAccessToken('fresh-token'); + + $this->assertFalse($client->isTokenExpired(), + 'Fresh token should not be considered expired'); + } + + /** + * Test 5: isTokenExpired() returns true after 24 hours (absolute expiration) + */ + public function testTokenExpiredAfter24Hours(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Set token that was created 24+ hours ago + // Using negative expiresIn means token expired X seconds ago + $client->setAccessToken('expired-token', -1); // Expired 1 second ago + + $this->assertTrue($client->isTokenExpired(), + 'Token created 24+ hours ago should be expired'); + } + + /** + * Test 6: isTokenExpired() returns true after 60 minutes of inactivity + */ + public function testTokenExpiredAfter60MinutesInactivity(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Create a token, then manually set last activity to 61 minutes ago + $client->setAccessToken('inactive-token'); + + // Access internal state via reflection to simulate 61 minutes of inactivity + $reflection = new \ReflectionClass($client); + $lastActivityProp = $reflection->getProperty('tokenLastActivityTime'); + $lastActivityProp->setAccessible(true); + $lastActivityProp->setValue($client, time() - 3660); // 61 minutes ago + + $this->assertTrue($client->isTokenExpired(), + 'Token with 60+ minutes of inactivity should be expired'); + } + + /** + * Test 7: isTokenExpired() returns false if activity occurred within 60 minutes + */ + public function testTokenNotExpiredIfRecentActivity(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Set token created 2 hours ago (past 60 minutes) + $client->setAccessToken('old-but-active-token', 86400 - 7200); // Created 2 hours ago + + // But manually set last activity to 30 minutes ago (recent) + $reflection = new \ReflectionClass($client); + $lastActivityProp = $reflection->getProperty('tokenLastActivityTime'); + $lastActivityProp->setAccessible(true); + $lastActivityProp->setValue($client, time() - 1800); // 30 minutes ago + + $this->assertFalse($client->isTokenExpired(), + 'Token with recent activity (< 60min) should not be expired'); + } + + /** + * Test 8: getTokenExpirationTime() returns correct Unix timestamp + */ + public function testGetTokenExpirationTimeReturnsCorrectTimestamp(): void + { + $client = new \FamilySearch(['sessions' => false]); + + $beforeTime = time(); + $client->setAccessToken('test-token'); + $afterTime = time(); + + $expirationTime = $client->getTokenExpirationTime(); + + // For fresh token, expiration should be ~60 minutes from now + // (inactivity expiration is sooner than 24-hour absolute) + $expectedExpiration = $beforeTime + 3600; // 60 minutes + $this->assertGreaterThanOrEqual($expectedExpiration - 2, $expirationTime); + $this->assertLessThanOrEqual($afterTime + 3600 + 2, $expirationTime); + } + + // ======================================================================== + // Expiration Threshold Tests + // ======================================================================== + + /** + * Test 9: Token near-expiration detection (within threshold, default 5 minutes) + */ + public function testNearExpirationDetection(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 300 // 5 minutes (default) + ]); + + // Set token that expires in 4 minutes (within threshold) + // Token was created (60 - 4) = 56 minutes ago + $client->setAccessToken('near-expiry-token', 240); // 4 minutes remaining + + $this->assertTrue($client->isTokenExpired(), + 'Token within 5-minute threshold should be considered expired'); + } + + /** + * Test 10: Custom threshold configuration works correctly + */ + public function testCustomThresholdConfiguration(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 600 // 10 minutes + ]); + + // Token expires in 8 minutes (within 10-minute threshold) + $client->setAccessToken('custom-threshold-token', 480); // 8 minutes remaining + + $this->assertTrue($client->isTokenExpired(), + 'Token within custom threshold should be considered expired'); + } + + /** + * Test 11: Near-expiration detection for both 24-hour and 60-minute boundaries + */ + public function testNearExpirationBothBoundaries(): void + { + // Test near 60-minute boundary + $client1 = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 300 // 5 minutes + ]); + + $client1->setAccessToken('near-60min-boundary', 240); // 4 minutes until inactivity expiration + $this->assertTrue($client1->isTokenExpired(), + 'Token near 60-minute inactivity boundary should be detected'); + + // Test near 24-hour boundary + $client2 = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 300 // 5 minutes + ]); + + // Token created almost 24 hours ago (4 minutes until absolute expiration) + // expiresIn = 240 means 4 minutes left, so created (24hrs - 4min) ago + $client2->setAccessToken('near-24hr-boundary', 240); + + // Manually update last activity to be recent (so inactivity doesn't trigger) + $reflection = new \ReflectionClass($client2); + $lastActivityProp = $reflection->getProperty('tokenLastActivityTime'); + $lastActivityProp->setAccessible(true); + $lastActivityProp->setValue($client2, time() - 60); // 1 minute ago + + // Token should still be considered expired due to absolute expiration threshold + $this->assertTrue($client2->isTokenExpired(), + 'Token near 24-hour absolute boundary should be detected'); + } + + // ======================================================================== + // Callback Tests + // ======================================================================== + + /** + * Test 12: onAuthenticationFailure callback triggered on 401 with correct parameters + */ + public function testCallbackTriggeredOn401WithCorrectParameters(): void + { + $callbackInvoked = false; + $receivedResponse = null; + $receivedReason = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'replayFailedRequestsAfterAuth' => false, // Disable replay for this test + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, &$receivedResponse, &$receivedReason) { + $callbackInvoked = true; + $receivedResponse = $response; + $receivedReason = $reason; + } + ]); + + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked, 'Callback should be invoked on 401'); + $this->assertIsObject($receivedResponse, 'Response parameter should be an object'); + $this->assertEquals(401, $receivedResponse->statusCode); + $this->assertIsString($receivedReason, 'Reason should be a string'); + $this->assertContains($receivedReason, ['expired', 'invalid']); + } + + /** + * Test 13: Callback receives 'expired' reason when isTokenExpired() returns true + */ + public function testCallbackReceivesExpiredReasonForExpiredToken(): void + { + $receivedReason = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'expirationWarningThreshold' => 0, + 'replayFailedRequestsAfterAuth' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$receivedReason) { + $receivedReason = $reason; + } + ]); + + // Set token that appears expired + $client->setAccessToken('expired-token', -3600); // Expired 1 hour ago + + $response = $client->get('/platform/users/current'); + + $this->assertEquals('expired', $receivedReason, + 'Callback should receive "expired" reason for expired token'); + } + + /** + * Test 14: Callback receives 'invalid' reason when isTokenExpired() returns false + */ + public function testCallbackReceivesInvalidReasonForNonExpiredToken(): void + { + $receivedReason = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'expirationWarningThreshold' => 0, + 'replayFailedRequestsAfterAuth' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$receivedReason) { + $receivedReason = $reason; + } + ]); + + // Set fresh token that's invalid (not expired, just wrong) + $client->setAccessToken('invalid-but-not-expired-token'); + + $response = $client->get('/platform/users/current'); + + $this->assertEquals('invalid', $receivedReason, + 'Callback should receive "invalid" reason for non-expired but invalid token'); + } + + /** + * Test 15: Request replay after token refresh in callback + */ + public function testRequestReplayAfterTokenRefreshInCallback(): void + { + $callbackInvoked = false; + $clientRef = null; + + $creds = $this->getCredentials(); + + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'replayFailedRequestsAfterAuth' => true, // Enable replay + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked, $creds, &$clientRef) { + $callbackInvoked = true; + // Re-authenticate to get new token + $clientRef->oauthPassword($creds['username'], $creds['password']); + } + ]); + + $clientRef = $client; + + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked); + $this->assertLessThan(400, $response->statusCode, 'Request should succeed after replay'); + $this->assertTrue($response->replayed ?? false, 'Response should be marked as replayed'); + } + + /** + * Test 16: No replay when token is not refreshed in callback + */ + public function testNoReplayWhenTokenNotRefreshed(): void + { + $callbackInvoked = false; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + // Callback doesn't obtain new token + } + ]); + + $response = $client->get('/platform/users/current'); + + $this->assertTrue($callbackInvoked); + $this->assertEquals(401, $response->statusCode, 'Should return 401 when no token refresh'); + $this->assertObjectNotHasProperty('replayed', $response); + } + + /** + * Test 17: Replay only happens once (second 401 doesn't retry again) + */ + public function testReplayOnlyHappensOnce(): void + { + $callbackCount = 0; + $clientRef = null; + + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'accessToken' => 'invalid-token-1', + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackCount, &$clientRef) { + $callbackCount++; + // Set another invalid token (replay will fail too) + $clientRef->setAccessToken('invalid-token-' . ($callbackCount + 1)); + } + ]); + + $clientRef = $client; + + $response = $client->get('/platform/users/current'); + + // Callback invoked twice: once for original, once for replay + // But should not infinite loop + $this->assertLessThanOrEqual(2, $callbackCount, + 'Callback should not be invoked more than twice (original + one replay)'); + $this->assertEquals(401, $response->statusCode); + } + + // ======================================================================== + // Backward Compatibility Tests + // ======================================================================== + + /** + * Test 18: Existing code using getAccessToken() as string still works + */ + public function testBackwardCompatibilityGetAccessTokenAsString(): void + { + $client = new \FamilySearch(['sessions' => false]); + $client->setAccessToken('my-token'); + + $token = $client->getAccessToken(); + + $this->assertIsString($token, 'getAccessToken() should return string by default'); + $this->assertEquals('my-token', $token); + } + + /** + * Test 19: Enhanced getAccessToken() returns array with token and expiration info + */ + public function testEnhancedGetAccessTokenReturnsArray(): void + { + $client = new \FamilySearch(['sessions' => false]); + $client->setAccessToken('detailed-token'); + + $details = $client->getAccessToken(true); + + $this->assertIsArray($details); + $this->assertArrayHasKey('token', $details); + $this->assertArrayHasKey('created', $details); + $this->assertArrayHasKey('last_activity', $details); + $this->assertArrayHasKey('expires_at', $details); + $this->assertArrayHasKey('is_expired', $details); + + $this->assertEquals('detailed-token', $details['token']); + $this->assertIsInt($details['created']); + $this->assertIsInt($details['last_activity']); + $this->assertIsInt($details['expires_at']); + $this->assertIsBool($details['is_expired']); + } + + /** + * Test 20: setAccessToken($token, $expiresIn) properly stores expiration time + */ + public function testSetAccessTokenWithExpiresInStoresExpiration(): void + { + $client = new \FamilySearch(['sessions' => false]); + + // Set token that expires in 1 hour + $beforeTime = time(); + $client->setAccessToken('token-with-expiry', 3600); + $afterTime = time(); + + $expirationTime = $client->getTokenExpirationTime(); + + // Should expire approximately 1 hour from now + $expectedExpiration = $beforeTime + 3600; + $this->assertGreaterThanOrEqual($expectedExpiration - 2, $expirationTime); + $this->assertLessThanOrEqual($afterTime + 3600 + 2, $expirationTime); + } + + /** + * Test 21: setAccessToken($token) without expiresIn uses 24-hour default + */ + public function testSetAccessTokenWithoutExpiresInUses24HourDefault(): void + { + $client = new \FamilySearch(['sessions' => false]); + + $beforeTime = time(); + $client->setAccessToken('default-expiry-token'); + $afterTime = time(); + + $details = $client->getAccessToken(true); + + // Creation time should be now + $this->assertGreaterThanOrEqual($beforeTime, $details['created']); + $this->assertLessThanOrEqual($afterTime, $details['created']); + + // Expiration should be ~60 minutes (inactivity) not 24 hours (absolute) + // For fresh token, inactivity expiration is sooner + $expirationTime = $client->getTokenExpirationTime(); + $expectedInactivityExpiration = $beforeTime + 3600; // 60 minutes + $this->assertGreaterThanOrEqual($expectedInactivityExpiration - 2, $expirationTime); + $this->assertLessThanOrEqual($afterTime + 3600 + 2, $expirationTime); + } + + // ======================================================================== + // Edge Cases + // ======================================================================== + + /** + * Test 22: Tokens already expired when set (past timestamp) + */ + public function testTokenAlreadyExpiredWhenSet(): void + { + $client = new \FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Set token that expired 2 hours ago + $client->setAccessToken('already-expired-token', -7200); + + $this->assertTrue($client->isTokenExpired(), + 'Token that was already expired should be considered expired'); + + $expirationTime = $client->getTokenExpirationTime(); + $this->assertLessThan(time(), $expirationTime, + 'Expiration time should be in the past'); + } + + /** + * Test 23: OAuth response without expires_in (FamilySearch normal behavior) + */ + public function testOAuthResponseWithoutExpiresIn(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false + ]); + + $beforeTime = time(); + $response = $client->oauthPassword($creds['username'], $creds['password']); + $afterTime = time(); + + $this->assertResponseOK($response); + + // FamilySearch OAuth doesn't return expires_in, but SDK should track it + $details = $client->getAccessToken(true); + $this->assertNotNull($details['created']); + $this->assertNotNull($details['last_activity']); + $this->assertNotNull($details['expires_at']); + + // Timestamps should be recent + $this->assertGreaterThanOrEqual($beforeTime, $details['created']); + $this->assertLessThanOrEqual($afterTime, $details['created']); + } + + /** + * Test 24: Activity updates extend inactivity window but not absolute expiration + */ + public function testActivityExtendsInactivityButNotAbsoluteExpiration(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false + ]); + + // Authenticate + $client->oauthPassword($creds['username'], $creds['password']); + + // Manually set creation time to 23 hours ago (close to absolute expiration) + $reflection = new \ReflectionClass($client); + $createdProp = $reflection->getProperty('tokenCreationTime'); + $createdProp->setAccessible(true); + $createdProp->setValue($client, time() - 82800); // 23 hours ago + + // Set last activity to recent (30 minutes ago) + $lastActivityProp = $reflection->getProperty('tokenLastActivityTime'); + $lastActivityProp->setAccessible(true); + $lastActivityProp->setValue($client, time() - 1800); // 30 minutes ago + + $expirationTime = $client->getTokenExpirationTime(); + + // Expiration should be based on absolute (creation + 24hrs), not inactivity + // Because absolute expiration (1 hour from now) is sooner than inactivity (30 minutes from now) + $absoluteExpiration = (time() - 82800) + 86400; // Creation + 24 hours + $inactivityExpiration = (time() - 1800) + 3600; // Last activity + 60 minutes + + $expected = min($absoluteExpiration, $inactivityExpiration); + $this->assertEqualsWithDelta($expected, $expirationTime, 2, + 'Expiration should be the sooner of absolute or inactivity'); + } + + /** + * Test 25: Token expiring between checking isTokenExpired() and making request + */ + public function testTokenExpiringBetweenCheckAndRequest(): void + { + $creds = $this->getCredentials(); + $client = new \FamilySearch([ + 'appKey' => $creds['api_key'], + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Authenticate + $client->oauthPassword($creds['username'], $creds['password']); + + // Check token is not expired + $this->assertFalse($client->isTokenExpired(), 'Fresh token should not be expired'); + + // Make request immediately (token still valid) + $response = $client->get('/platform/users/current'); + $this->assertResponseOK($response); + + // Now manually expire the token by setting last activity to past + $reflection = new \ReflectionClass($client); + $lastActivityProp = $reflection->getProperty('tokenLastActivityTime'); + $lastActivityProp->setAccessible(true); + $lastActivityProp->setValue($client, time() - 3660); // 61 minutes ago + + // Token should now be expired + $this->assertTrue($client->isTokenExpired(), 'Token should be expired after inactivity'); + + // Next request should fail with 401 (simulating the edge case scenario) + // In real usage, the callback would handle re-authentication + } +} diff --git a/tests/Unit/FamilySearchAuthCallbackTest.php b/tests/Unit/FamilySearchAuthCallbackTest.php new file mode 100644 index 0000000..9e573c1 --- /dev/null +++ b/tests/Unit/FamilySearchAuthCallbackTest.php @@ -0,0 +1,392 @@ + false]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that callback can be configured with a closure + */ + public function testCallbackWithClosure(): void + { + $callbackInvoked = false; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that callback can be configured with a function name + */ + public function testCallbackWithFunctionName(): void + { + // Create a global function for testing + if (!function_exists('testAuthCallback')) { + eval('function testAuthCallback($response, $reason) {}'); + } + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => 'testAuthCallback' + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that callback can be configured with array [object, method] + */ + public function testCallbackWithObjectMethod(): void + { + $handler = new class { + public function handleAuthFailure($response, $reason) { + // Handler implementation + } + }; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => [$handler, 'handleAuthFailure'] + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that non-callable throws InvalidArgumentException + */ + public function testNonCallableThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('onAuthenticationFailure must be a callable'); + + new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => 'not-a-function' + ]); + } + + /** + * Test that string (non-function) throws exception + */ + public function testStringNonFunctionThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + + new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => 'invalid_callback_name' + ]); + } + + /** + * Test that array with invalid method throws exception + */ + public function testArrayWithInvalidMethodThrowsException(): void + { + $handler = new class {}; + + $this->expectException(\InvalidArgumentException::class); + + new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => [$handler, 'nonExistentMethod'] + ]); + } + + /** + * Test that integer throws exception + */ + public function testIntegerThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + + new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => 123 + ]); + } + + /** + * Test that null is allowed (no callback) + */ + public function testNullIsAllowed(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => null + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test callback signature with mock response object + */ + public function testCallbackReceivesCorrectParameters(): void + { + $receivedResponse = null; + $receivedReason = null; + + $callback = function($response, $reason) use (&$receivedResponse, &$receivedReason) { + $receivedResponse = $response; + $receivedReason = $reason; + }; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => $callback + ]); + + // Create a mock response object + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 401; + $mockResponse->body = '{"error": "unauthorized"}'; + + // Manually invoke callback to test parameters + // (In real usage, this is called internally by request() method) + call_user_func($callback, $mockResponse, 'expired'); + + $this->assertSame($mockResponse, $receivedResponse); + $this->assertEquals('expired', $receivedReason); + } + + /** + * Test that callback can call setAccessToken to recover from auth failure + */ + public function testCallbackCanCallSetAccessToken(): void + { + $newTokenSet = false; + $fsInstance = null; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$fsInstance, &$newTokenSet) { + // Simulate re-authentication by setting new token + if ($fsInstance !== null) { + $fsInstance->setAccessToken('new-token-after-reauth'); + $newTokenSet = true; + } + } + ]); + + // Store reference for callback + $fsInstance = $fs; + + // Set initial token + $fs->setAccessToken('expired-token'); + $this->assertEquals('expired-token', $fs->getAccessToken()); + + // Manually trigger callback (simulating 401 response) + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 401; + + // Invoke callback manually for testing + call_user_func( + function($response, $reason) use (&$fsInstance, &$newTokenSet) { + if ($fsInstance !== null) { + $fsInstance->setAccessToken('new-token-after-reauth'); + $newTokenSet = true; + } + }, + $mockResponse, + 'expired' + ); + + $this->assertTrue($newTokenSet); + $this->assertEquals('new-token-after-reauth', $fs->getAccessToken()); + } + + /** + * Test reason determination: 'expired' when isTokenExpired() returns true + */ + public function testReasonIsExpiredWhenTokenExpired(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Set token that was created long ago (will be considered expired) + $fs->setAccessToken('old-token', -1); // Negative expiresIn = already expired + + // Token should be considered expired + $this->assertTrue($fs->isTokenExpired()); + + // When 401 occurs with expired token, reason should be 'expired' + // (This is tested in integration tests where real requests are made) + } + + /** + * Test reason determination: 'invalid' when token exists but not expired + */ + public function testReasonIsInvalidWhenTokenNotExpired(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + // Set fresh token (not expired) + $fs->setAccessToken('invalid-but-not-expired-token'); + + // Token should not be considered expired + $this->assertFalse($fs->isTokenExpired()); + + // When 401 occurs with non-expired token, reason should be 'invalid' + // (This is tested in integration tests where real requests are made) + } + + /** + * Test multiple callbacks configuration (last one wins) + */ + public function testMultipleCallbacksLastOneWins(): void + { + $firstCallbackInvoked = false; + $secondCallbackInvoked = false; + + // This won't work as expected since we can't reconfigure after construction + // This test just ensures constructor handles the option correctly + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$secondCallbackInvoked) { + $secondCallbackInvoked = true; + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test callback with class method using invokable object + */ + public function testCallbackWithInvokableObject(): void + { + $handler = new class { + public $invoked = false; + + public function __invoke($response, $reason) { + $this->invoked = true; + } + }; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => $handler + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + + // Test invocation + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 401; + $handler($mockResponse, 'expired'); + + $this->assertTrue($handler->invoked); + } + + /** + * Test that callback is optional and SDK continues to work without it + */ + public function testBackwardCompatibilityWithoutCallback(): void + { + // Create SDK without callback (old behavior) + $fs = new FamilySearch([ + 'sessions' => false, + 'appKey' => 'test-key' + ]); + + // Set a token + $fs->setAccessToken('test-token'); + + // SDK should work normally + $this->assertEquals('test-token', $fs->getAccessToken()); + $this->assertFalse($fs->isTokenExpired()); + } + + /** + * Test callback with password re-authentication pattern + */ + public function testCallbackPasswordReauthenticationPattern(): void + { + $reauthenticated = false; + $username = 'testuser'; + $password = 'testpass'; + + $callback = function($response, $reason) use (&$reauthenticated, $username, $password) { + if ($reason === 'expired') { + // In real usage, would call: $this->oauthPassword($username, $password) + // For testing, just set flag + $reauthenticated = true; + } + }; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => $callback + ]); + + // Simulate callback invocation + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 401; + call_user_func($callback, $mockResponse, 'expired'); + + $this->assertTrue($reauthenticated); + } + + /** + * Test callback with redirect pattern + */ + public function testCallbackRedirectPattern(): void + { + $shouldRedirect = false; + + $callback = function($response, $reason) use (&$shouldRedirect) { + if ($reason === 'invalid') { + // In real usage, would do: header('Location: /login'); exit; + // For testing, just set flag + $shouldRedirect = true; + } + }; + + $fs = new FamilySearch([ + 'sessions' => false, + 'onAuthenticationFailure' => $callback + ]); + + // Simulate callback invocation with 'invalid' reason + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 401; + call_user_func($callback, $mockResponse, 'invalid'); + + $this->assertTrue($shouldRedirect); + } +} diff --git a/tests/Unit/FamilySearchRequestReplayTest.php b/tests/Unit/FamilySearchRequestReplayTest.php new file mode 100644 index 0000000..f4411e0 --- /dev/null +++ b/tests/Unit/FamilySearchRequestReplayTest.php @@ -0,0 +1,306 @@ + false]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that replay can be disabled via configuration + */ + public function testReplayCanBeDisabled(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => false + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that replay can be explicitly enabled via configuration + */ + public function testReplayCanBeExplicitlyEnabled(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that non-boolean value for replay option is ignored + */ + public function testNonBooleanReplayOptionIgnored(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => 'invalid' + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test backward compatibility - SDK works without replay configuration + */ + public function testBackwardCompatibilityWithoutReplayConfig(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'appKey' => 'test-key' + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + $this->assertNull($fs->getAccessToken()); + } + + /** + * Test that setAccessToken works normally outside of callback + */ + public function testSetAccessTokenWorksNormally(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $fs->setAccessToken('test-token-123'); + + $this->assertEquals('test-token-123', $fs->getAccessToken()); + } + + /** + * Test replay configuration with callback + */ + public function testReplayConfigurationWithCallback(): void + { + $callbackInvoked = false; + + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test replay disabled configuration with callback + */ + public function testReplayDisabledWithCallback(): void + { + $callbackInvoked = false; + + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + $callbackInvoked = true; + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that callback without setAccessToken doesn't trigger replay + * + * This test verifies that if the callback doesn't obtain a new token, + * no replay is attempted (even if replay is enabled). + */ + public function testCallbackWithoutTokenChangeDoesntTriggerReplay(): void + { + $callbackInvoked = false; + + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true, + 'onAuthenticationFailure' => function($response, $reason) use (&$callbackInvoked) { + // Callback doesn't obtain new token + $callbackInvoked = true; + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test multiple configuration options together + */ + public function testMultipleConfigurationOptions(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'appKey' => 'test-key', + 'environment' => 'integration', + 'replayFailedRequestsAfterAuth' => true, + 'expirationWarningThreshold' => 600, + 'onAuthenticationFailure' => function($response, $reason) { + // Handler + } + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that replay works with sessions enabled + */ + public function testReplayWithSessionsEnabled(): void + { + $fs = new FamilySearch([ + 'sessions' => true, + 'replayFailedRequestsAfterAuth' => true + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that replay works with encryption enabled + */ + public function testReplayWithEncryptionEnabled(): void + { + $key = base64_encode(random_bytes(32)); + + $fs = new FamilySearch([ + 'sessions' => true, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $key, + 'replayFailedRequestsAfterAuth' => true + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test getAccessToken works normally with replay enabled + */ + public function testGetAccessTokenWithReplayEnabled(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true + ]); + + $fs->setAccessToken('my-token'); + + $this->assertEquals('my-token', $fs->getAccessToken()); + + $details = $fs->getAccessToken(true); + $this->assertEquals('my-token', $details['token']); + } + + /** + * Test isTokenExpired works with replay enabled + */ + public function testIsTokenExpiredWithReplayEnabled(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true, + 'expirationWarningThreshold' => 0 + ]); + + // No token - should be expired + $this->assertTrue($fs->isTokenExpired()); + + // Fresh token - should not be expired + $fs->setAccessToken('fresh-token'); + $this->assertFalse($fs->isTokenExpired()); + } + + /** + * Test that setAccessToken with expiresIn works with replay + */ + public function testSetAccessTokenWithExpiresInAndReplay(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true + ]); + + $fs->setAccessToken('token-with-expiry', 3600); + + $this->assertEquals('token-with-expiry', $fs->getAccessToken()); + } + + /** + * Test replay option with null value (should use default) + */ + public function testReplayOptionWithNull(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => null + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test replay configuration doesn't affect normal operations + */ + public function testReplayConfigDoesntAffectNormalOperations(): void + { + $fs1 = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true + ]); + + $fs2 = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => false + ]); + + $fs1->setAccessToken('token1'); + $fs2->setAccessToken('token2'); + + $this->assertEquals('token1', $fs1->getAccessToken()); + $this->assertEquals('token2', $fs2->getAccessToken()); + } + + /** + * Test that replay doesn't interfere with token expiration tracking + */ + public function testReplayDoesntInterfereWithExpirationTracking(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'replayFailedRequestsAfterAuth' => true + ]); + + $beforeTime = time(); + $fs->setAccessToken('tracked-token'); + $afterTime = time(); + + $details = $fs->getAccessToken(true); + $this->assertGreaterThanOrEqual($beforeTime, $details['created']); + $this->assertLessThanOrEqual($afterTime, $details['created']); + $this->assertNotNull($details['expires_at']); + } +} diff --git a/tests/Unit/FamilySearchTokenExpirationTest.php b/tests/Unit/FamilySearchTokenExpirationTest.php new file mode 100644 index 0000000..d7891d2 --- /dev/null +++ b/tests/Unit/FamilySearchTokenExpirationTest.php @@ -0,0 +1,335 @@ + false]); + + $this->assertTrue($fs->isTokenExpired()); + } + + /** + * Test that getTokenExpirationTime returns null when no token is present + */ + public function testGetTokenExpirationTimeWithNoToken(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $this->assertNull($fs->getTokenExpirationTime()); + } + + /** + * Test setAccessToken with null expiresIn (fresh token) + */ + public function testSetAccessTokenWithNullExpiresIn(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $beforeTime = time(); + $fs->setAccessToken('test-token-123'); + $afterTime = time(); + + $this->assertEquals('test-token-123', $fs->getAccessToken()); + + $details = $fs->getAccessToken(true); + $this->assertEquals('test-token-123', $details['token']); + $this->assertNotNull($details['created']); + $this->assertNotNull($details['last_activity']); + $this->assertGreaterThanOrEqual($beforeTime, $details['created']); + $this->assertLessThanOrEqual($afterTime, $details['created']); + $this->assertEquals($details['created'], $details['last_activity']); + } + + /** + * Test setAccessToken with specific expiresIn value + */ + public function testSetAccessTokenWithExpiresIn(): void + { + $fs = new FamilySearch(['sessions' => false]); + + // Set token that expires in 1 hour (3600 seconds) + $beforeTime = time(); + $fs->setAccessToken('test-token-456', 3600); + $afterTime = time(); + + $this->assertEquals('test-token-456', $fs->getAccessToken()); + + $details = $fs->getAccessToken(true); + // Creation time should be calculated as (now - (24 hours - expiresIn)) + // For expiresIn=3600, creation was 24hrs - 1hr = 23 hours ago + $expectedCreation = $beforeTime - (86400 - 3600); + $this->assertGreaterThanOrEqual($expectedCreation - 1, $details['created']); + $this->assertLessThanOrEqual($expectedCreation + 1, $details['created']); + } + + /** + * Test getAccessToken backward compatibility (returns string by default) + */ + public function testGetAccessTokenBackwardCompatibility(): void + { + $fs = new FamilySearch(['sessions' => false]); + $fs->setAccessToken('my-token'); + + $token = $fs->getAccessToken(); + $this->assertIsString($token); + $this->assertEquals('my-token', $token); + } + + /** + * Test getAccessToken with detailed=true returns array + */ + public function testGetAccessTokenDetailed(): void + { + $fs = new FamilySearch(['sessions' => false]); + $fs->setAccessToken('detailed-token'); + + $details = $fs->getAccessToken(true); + $this->assertIsArray($details); + $this->assertArrayHasKey('token', $details); + $this->assertArrayHasKey('created', $details); + $this->assertArrayHasKey('last_activity', $details); + $this->assertArrayHasKey('expires_at', $details); + $this->assertArrayHasKey('is_expired', $details); + + $this->assertEquals('detailed-token', $details['token']); + $this->assertIsInt($details['created']); + $this->assertIsInt($details['last_activity']); + $this->assertIsInt($details['expires_at']); + $this->assertIsBool($details['is_expired']); + } + + /** + * Test getTokenExpirationTime calculates correctly + */ + public function testGetTokenExpirationTimeCalculation(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $beforeTime = time(); + $fs->setAccessToken('expiration-test-token'); + $afterTime = time(); + + $expirationTime = $fs->getTokenExpirationTime(); + + // Token should expire 24 hours from creation OR 60 minutes from last activity + // For a fresh token, both are set to now, so expiration is min(now+24hrs, now+60min) = now+60min + $expectedExpiration = $beforeTime + 3600; // 60 minutes + $this->assertGreaterThanOrEqual($expectedExpiration - 1, $expirationTime); + $this->assertLessThanOrEqual($afterTime + 3600 + 1, $expirationTime); + } + + /** + * Test isTokenExpired with fresh token (should not be expired) + */ + public function testIsTokenExpiredWithFreshToken(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 // No warning threshold + ]); + + $fs->setAccessToken('fresh-token'); + + $this->assertFalse($fs->isTokenExpired()); + } + + /** + * Test isTokenExpired with token within warning threshold + */ + public function testIsTokenExpiredWithinWarningThreshold(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 3600 // 60 minute warning (same as inactivity timeout) + ]); + + $fs->setAccessToken('warning-token'); + + // With 60 minute warning threshold and 60 minute inactivity expiration, + // token should be considered expired immediately + $this->assertTrue($fs->isTokenExpired()); + } + + /** + * Test custom expirationWarningThreshold configuration + */ + public function testCustomExpirationWarningThreshold(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 600 // 10 minutes + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test that default expirationWarningThreshold is 300 seconds + */ + public function testDefaultExpirationWarningThreshold(): void + { + $fs = new FamilySearch(['sessions' => false]); + $fs->setAccessToken('default-threshold-token'); + + // With default 5-minute threshold, fresh token should not be expired + // (expires in 60 minutes, threshold is 5 minutes) + $this->assertFalse($fs->isTokenExpired()); + } + + /** + * Test getAccessToken returns null when no token is set + */ + public function testGetAccessTokenReturnsNullWithNoToken(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $this->assertNull($fs->getAccessToken()); + } + + /** + * Test getAccessToken detailed returns null token when no token is set + */ + public function testGetAccessTokenDetailedWithNoToken(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $details = $fs->getAccessToken(true); + $this->assertIsArray($details); + $this->assertNull($details['token']); + $this->assertNull($details['created']); + $this->assertNull($details['last_activity']); + $this->assertNull($details['expires_at']); + $this->assertTrue($details['is_expired']); + } + + /** + * Test that token expiration considers absolute expiration (24 hours) + */ + public function testTokenExpirationAbsoluteLimit(): void + { + $fs = new FamilySearch(['sessions' => false]); + + // Set token that was created 23 hours ago (has 1 hour left on absolute expiration) + // But last activity is now, so inactivity expiration is in 60 minutes + // Expiration should be min(1 hour from now, 60 minutes from now) = 60 minutes + $fs->setAccessToken('absolute-test-token', 3600); // 1 hour remaining + + $expirationTime = $fs->getTokenExpirationTime(); + $expectedExpiration = time() + 3600; // Should expire in 60 minutes (inactivity) + + $this->assertEqualsWithDelta($expectedExpiration, $expirationTime, 2); + } + + /** + * Test constructor accessToken option initializes timestamps + */ + public function testConstructorAccessTokenOption(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'accessToken' => 'constructor-token' + ]); + + // When setting token via constructor without timestamp info, + // token should be present but timestamps may not be initialized + $this->assertEquals('constructor-token', $fs->getAccessToken()); + } + + /** + * Test zero expirationWarningThreshold + */ + public function testZeroExpirationWarningThreshold(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 0 + ]); + + $fs->setAccessToken('zero-threshold-token'); + + // With zero threshold, only truly expired tokens should return true + // Fresh token should not be expired + $this->assertFalse($fs->isTokenExpired()); + } + + /** + * Test large expirationWarningThreshold (larger than token lifetime) + */ + public function testLargeExpirationWarningThreshold(): void + { + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 90000 // 25 hours (more than token lifetime) + ]); + + $fs->setAccessToken('large-threshold-token'); + + // With threshold larger than token lifetime, all tokens should be considered expired + $this->assertTrue($fs->isTokenExpired()); + } + + /** + * Test that invalid expirationWarningThreshold types are ignored + */ + public function testInvalidExpirationWarningThresholdType(): void + { + // String should be ignored, default should be used + $fs = new FamilySearch([ + 'sessions' => false, + 'expirationWarningThreshold' => 'invalid' + ]); + + $this->assertInstanceOf(FamilySearch::class, $fs); + } + + /** + * Test negative expiresIn value for setAccessToken + */ + public function testSetAccessTokenWithNegativeExpiresIn(): void + { + $fs = new FamilySearch(['sessions' => false]); + + // Negative expiresIn means token already expired + $fs->setAccessToken('expired-token', -3600); + + // Token should be considered expired + $details = $fs->getAccessToken(true); + $this->assertTrue($details['is_expired']); + } + + /** + * Test that setting token multiple times updates timestamps + */ + public function testSetAccessTokenMultipleTimes(): void + { + $fs = new FamilySearch(['sessions' => false]); + + $fs->setAccessToken('first-token'); + $firstDetails = $fs->getAccessToken(true); + $firstCreated = $firstDetails['created']; + + // Wait a moment and set new token + sleep(1); + + $fs->setAccessToken('second-token'); + $secondDetails = $fs->getAccessToken(true); + $secondCreated = $secondDetails['created']; + + $this->assertEquals('second-token', $secondDetails['token']); + $this->assertGreaterThan($firstCreated, $secondCreated); + } +}