From 1f16972a553577883afdc929da5c15caf789938e Mon Sep 17 00:00:00 2001 From: neil_pack Date: Fri, 21 Aug 2026 09:40:25 -0600 Subject: [PATCH 1/4] Implemented Token Expiration Handling and Auto-Refresh --- README.md | 86 ++ docs/TOKEN_EXPIRATION.md | 1200 +++++++++++++++++ src/FamilySearch.php | 691 +++++++++- .../AuthenticationCallbackTest.php | 260 ++++ .../Integration/EncryptedSessionFlowTest.php | 14 +- tests/Integration/RequestReplayTest.php | 339 +++++ .../TokenExpirationComprehensiveTest.php | 669 +++++++++ tests/Unit/FamilySearchAuthCallbackTest.php | 392 ++++++ tests/Unit/FamilySearchRequestReplayTest.php | 306 +++++ .../Unit/FamilySearchTokenExpirationTest.php | 335 +++++ 10 files changed, 4265 insertions(+), 27 deletions(-) create mode 100644 docs/TOKEN_EXPIRATION.md create mode 100644 tests/Integration/AuthenticationCallbackTest.php create mode 100644 tests/Integration/RequestReplayTest.php create mode 100644 tests/Integration/TokenExpirationComprehensiveTest.php create mode 100644 tests/Unit/FamilySearchAuthCallbackTest.php create mode 100644 tests/Unit/FamilySearchRequestReplayTest.php create mode 100644 tests/Unit/FamilySearchTokenExpirationTest.php diff --git a/README.md b/README.md index cf0ecba..4317c51 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,91 @@ For comprehensive security guidance, see **[SECURITY.md](SECURITY.md)** which in - 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 comprehensive documentation including: +- FamilySearch token behavior details +- Complete working examples +- Migration guide from manual 401 handling +- Configuration options reference +- Request replay behavior +- Activity tracking details + +See **[docs/TOKEN_EXPIRATION.md](docs/TOKEN_EXPIRATION.md)** + +This feature addresses [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) (opened 2016), which requested automatic token expiration handling and re-authentication support. + ## Serialization with gedcomx-php When the `objects` configuration option is set to true, the @@ -356,6 +441,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 diff --git a/docs/TOKEN_EXPIRATION.md b/docs/TOKEN_EXPIRATION.md new file mode 100644 index 0000000..87d450a --- /dev/null +++ b/docs/TOKEN_EXPIRATION.md @@ -0,0 +1,1200 @@ +# Token Expiration Handling + +## Overview + +The FamilySearch PHP Lite SDK provides comprehensive token expiration tracking and automatic re-authentication capabilities, addressing [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) from 2016. Prior to this enhancement, developers had to manually detect 401 responses and implement their own re-authentication logic. The SDK now provides three flexible approaches to handle token expiration transparently. + +## FamilySearch Token Behavior + +Before diving into implementation approaches, it's critical to understand how FamilySearch access tokens work: + +### Token Lifetime Characteristics + +**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 + +### Key Behaviors + +- ✅ **Each successful API call resets the 60-minute inactivity timer** + - Making an API call within 60 minutes keeps the token alive + - The token can remain valid for the full 24 hours if used regularly + +- ❌ **No refresh tokens available** + - FamilySearch does not support OAuth refresh token grants + - When a token expires, you must re-authenticate to obtain a completely new token + - This is different from many OAuth providers that support refresh tokens + +- 🔍 **No `expires_in` field in OAuth responses** + - The FamilySearch API returns: `{"access_token": "...", "token_type": "family_search"}` + - Unlike standard OAuth, there is no `expires_in` field + - The SDK tracks expiration **client-side** using timestamps + +- 🤝 **401 responses don't distinguish expiration causes** + - The API returns 401 for: expired tokens, invalid tokens, revoked tokens, missing tokens + - The SDK uses client-side tracking to determine the likely reason + +### Token Lifetime Calculation + +The SDK calculates token expiration as: + +```php +$expirationTime = min( + $tokenCreationTime + 86400, // 24 hours from creation + $tokenLastActivityTime + 3600 // 60 minutes from last activity +); +``` + +**Examples:** + +- **Fresh token, no activity**: Expires in 60 minutes (inactivity limit) +- **Token created 23 hours ago, used 30 minutes ago**: Expires in 1 hour (absolute limit) +- **Token created 2 hours ago, used 10 minutes ago**: Expires in 50 minutes (inactivity limit) + +## Approach 1: Proactive Expiration Checking + +The SDK provides methods to check token expiration **before** making API requests, allowing you to re-authenticate proactively. + +### Basic Expiration Check + +```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()) { + // Token is expired or within 5 minutes of expiration + // Re-authenticate before making requests + $fs->oauthPassword($username, $password); +} + +// Now safe to make API requests +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +``` + +### Display Expiration Time to User + +```php +$expirationTime = $fs->getTokenExpirationTime(); + +if ($expirationTime) { + $timeUntilExpiration = $expirationTime - time(); + $minutesRemaining = floor($timeUntilExpiration / 60); + + echo "Your session will expire in {$minutesRemaining} minutes\n"; + + if ($minutesRemaining < 10) { + echo "Warning: Your session is about to expire!\n"; + } +} +``` + +### Near-Expiration Detection + +```php +// Configure a custom warning threshold (e.g., 10 minutes) +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'expirationWarningThreshold' => 600 // 10 minutes +]); + +if ($fs->isTokenExpired()) { + // Token is expired OR within 10 minutes of expiration + echo "Time to re-authenticate!\n"; + $fs->oauthPassword($username, $password); +} +``` + +### Expiration Calculation Details + +The SDK determines expiration based on **the sooner** of the two limits: + +```php +// Get detailed token information +$tokenInfo = $fs->getAccessToken(true); + +echo "Token created: " . date('Y-m-d H:i:s', $tokenInfo['created']) . "\n"; +echo "Last activity: " . date('Y-m-d H:i:s', $tokenInfo['last_activity']) . "\n"; +echo "Expires at: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']) . "\n"; +echo "Is expired: " . ($tokenInfo['is_expired'] ? 'Yes' : 'No') . "\n"; + +// Calculate which expiration limit applies +$absoluteExpiration = $tokenInfo['created'] + 86400; // 24 hours +$inactivityExpiration = $tokenInfo['last_activity'] + 3600; // 60 minutes +$actualExpiration = min($absoluteExpiration, $inactivityExpiration); + +if ($actualExpiration === $absoluteExpiration) { + echo "Token will expire due to 24-hour absolute limit\n"; +} else { + echo "Token will expire due to 60-minute inactivity limit\n"; +} +``` + +## Approach 2: Authentication Failure Callback + +The SDK can automatically detect 401 responses and invoke a callback, allowing you to handle re-authentication transparently. + +### Basic Callback Configuration + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'onAuthenticationFailure' => function($response, $reason) { + // Called when a 401 response is received + // $response: Full response object with statusCode 401 + // $reason: 'expired' or 'invalid' + + error_log("Authentication failed: {$reason}"); + + if ($reason === 'expired') { + // Token expired - re-authenticate + } else { + // Token is invalid - may have been revoked + } + } +]); +``` + +### 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') { + error_log('Token expired, re-authenticating automatically...'); + + // Re-authenticate with password grant + $authResponse = $fs->oauthPassword($username, $password); + + if ($authResponse->statusCode === 200) { + error_log('Re-authentication successful'); + } else { + error_log('Re-authentication failed'); + // Handle re-authentication failure + } + } else { + error_log('Token invalid: ' . $reason); + // Token was revoked or is otherwise invalid + throw new Exception('Authentication token is invalid'); + } + } +]); + +// Make requests normally - re-authentication happens automatically +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +// With automatic replay enabled (default), the request succeeds transparently +if ($response->statusCode === 200) { + echo "Request succeeded!\n"; + + // Check if the request was replayed after re-authentication + if ($response->replayed ?? false) { + echo "Note: 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) { + // Store the original request URL in session for post-login redirect + $_SESSION['original_request'] = $_SERVER['REQUEST_URI']; + + if ($reason === 'expired') { + error_log('Session expired, redirecting to login...'); + } else { + error_log('Authentication invalid, redirecting to login...'); + } + + // Redirect user to FamilySearch OAuth authorization page + header('Location: ' . $fs->oauthRedirectURL()); + exit; + } +]); + +// Your OAuth callback handler: +// oauth/callback.php +$fs->oauthResponse(); // Exchanges code for token + +// Redirect back to original request +$originalRequest = $_SESSION['original_request'] ?? '/'; +header('Location: ' . $originalRequest); +exit; +``` + +### Unauthenticated Session Renewal + +**Best for:** Public API access, read-only operations + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'onAuthenticationFailure' => function($response, $reason) use (&$fs) { + if ($reason === 'expired') { + error_log('Unauthenticated session expired, requesting new session...'); + + // Request a new unauthenticated session token + // Note: This endpoint may vary - check FamilySearch API docs + $response = $fs->post('/platform/authentication/unauthenticated-session', [ + 'body' => ['client_id' => $_ENV['FS_APP_KEY']] + ]); + + if ($response->statusCode === 200) { + $fs->setAccessToken($response->data['access_token']); + error_log('New unauthenticated session obtained'); + } + } + } +]); +``` + +### Understanding Failure Reasons + +The callback receives a `$reason` parameter indicating why authentication failed: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'onAuthenticationFailure' => function($response, $reason) { + // $reason can be: + // - 'expired': SDK's client-side tracking indicates token should be expired + // - 'invalid': 401 occurred but token shouldn't be expired per tracking + + switch ($reason) { + case 'expired': + // Token expired due to: + // - 24 hours passed since creation, OR + // - 60 minutes passed since last activity + // + // Action: Re-authenticate to get new token + error_log('Token expired normally - re-authenticating'); + break; + + case 'invalid': + // Token is invalid but not expired according to tracking. + // Possible causes: + // - Token was manually revoked + // - Token was never valid (wrong token set) + // - Server rejected token for other reasons + // - Clock skew between client and server + // + // Action: Depends on your application logic + error_log('Token invalid - may have been revoked'); + break; + } + } +]); +``` + +**Important:** The FamilySearch API always returns 401 for any authentication failure. The SDK uses **client-side token tracking** to determine whether the failure was due to expiration (`'expired'`) or another reason (`'invalid'`). This is the SDK's best guess based on timestamps, not information from the API. + +## Approach 3: Enhanced Token Info Retrieval + +The SDK provides enhanced methods to retrieve detailed token information for manual management. + +### Get Detailed Token Information + +```php +// Backward compatible: getAccessToken() returns string +$token = $fs->getAccessToken(); +echo "Token: {$token}\n"; // String: "abc123..." + +// Enhanced: getAccessToken(true) returns array with metadata +$tokenInfo = $fs->getAccessToken(true); + +print_r($tokenInfo); +/* +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 +) +*/ + +// Use detailed info for custom logic +if ($tokenInfo['is_expired']) { + echo "Token is expired or expiring soon\n"; +} else { + $timeRemaining = $tokenInfo['expires_at'] - time(); + echo "Token valid for {$timeRemaining} more seconds\n"; +} +``` + +### Manual Token Management with `setAccessToken()` + +```php +// When manually setting a token, the SDK tracks expiration automatically +$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); + +// Option 1: Set token without expiration info (common with FamilySearch) +// SDK treats it as freshly issued: creation=now, last_activity=now +$fs->setAccessToken('your-access-token-here'); + +// Option 2: Set token with known remaining lifetime +// Use this when restoring a token from storage and you know how long it has left +$fs->setAccessToken('your-access-token-here', 3600); // Token has 1 hour remaining + +// The SDK calculates: +// - Creation time: now - (86400 - $expiresIn) +// - Last activity: now +// - Expiration: min(creation + 24hrs, last_activity + 60min) +``` + +### Custom Token Storage and Retrieval + +```php +// Example: Store token in database with expiration info +class TokenStorage { + public static function saveToken($token, $created, $lastActivity) { + $db = getDatabase(); + $db->query( + "INSERT INTO tokens (token, created_at, last_activity) VALUES (?, ?, ?)", + [$token, $created, $lastActivity] + ); + } + + public static function loadToken() { + $db = getDatabase(); + $row = $db->query("SELECT * FROM tokens WHERE user_id = ? ORDER BY id DESC LIMIT 1"); + + if ($row) { + // Calculate remaining time + $created = strtotime($row['created_at']); + $lastActivity = strtotime($row['last_activity']); + $absoluteExpiration = $created + 86400; + $inactivityExpiration = $lastActivity + 3600; + $expirationTime = min($absoluteExpiration, $inactivityExpiration); + $remainingTime = $expirationTime - time(); + + if ($remainingTime > 0) { + return [ + 'token' => $row['token'], + 'expires_in' => $remainingTime + ]; + } + } + + return null; + } +} + +// Use custom storage with SDK +$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); + +$tokenData = TokenStorage::loadToken(); +if ($tokenData) { + // Restore token with remaining lifetime + $fs->setAccessToken($tokenData['token'], $tokenData['expires_in']); +} else { + // No valid token - authenticate + $response = $fs->oauthPassword($username, $password); + + // Save new token + $tokenInfo = $fs->getAccessToken(true); + TokenStorage::saveToken( + $tokenInfo['token'], + $tokenInfo['created'], + $tokenInfo['last_activity'] + ); +} +``` + +## Activity Tracking + +The SDK automatically tracks API activity to manage the 60-minute inactivity window. + +### Automatic Activity Tracking + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production' +]); + +// Authenticate +$fs->oauthPassword($username, $password); + +// Each successful API call resets the 60-minute inactivity timer +$fs->get('/platform/tree/persons/PPPP-PPP'); // Last activity updated +sleep(1800); // Wait 30 minutes +$fs->get('/platform/collection'); // Last activity updated again +sleep(1800); // Wait another 30 minutes +$fs->get('/platform/users/current'); // Last activity updated again + +// Token remains valid because we used it within 60 minutes each time +// Without this activity, token would expire after 60 minutes of inactivity +``` + +### Check Time Until Expiration + +```php +$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); +$fs->oauthPassword($username, $password); + +function checkExpiration($fs) { + $expirationTime = $fs->getTokenExpirationTime(); + $now = time(); + + $secondsRemaining = $expirationTime - $now; + $minutesRemaining = floor($secondsRemaining / 60); + + echo "Token expires in: {$minutesRemaining} minutes\n"; + + // Get details about which limit applies + $tokenInfo = $fs->getAccessToken(true); + $absoluteExpiration = $tokenInfo['created'] + 86400; + $inactivityExpiration = $tokenInfo['last_activity'] + 3600; + + if ($expirationTime === $absoluteExpiration) { + echo "Expiring due to: 24-hour absolute limit\n"; + $hoursFromCreation = floor((time() - $tokenInfo['created']) / 3600); + echo "Token age: {$hoursFromCreation} hours\n"; + } else { + echo "Expiring due to: 60-minute inactivity limit\n"; + $minutesSinceActivity = floor((time() - $tokenInfo['last_activity']) / 60); + echo "Minutes since last activity: {$minutesSinceActivity}\n"; + } +} + +checkExpiration($fs); + +// Make an API call +$fs->get('/platform/users/current'); + +checkExpiration($fs); // Expiration extended due to activity +``` + +### Activity Tracking Behavior + +**What updates last activity:** +- ✅ Successful API responses (2xx status codes) +- ✅ Redirect responses (3xx status codes) + +**What does NOT update last activity:** +- ❌ 401 Unauthorized responses (authentication failures) +- ❌ 4xx Client errors (except redirects) +- ❌ 5xx Server errors +- ❌ Network failures or timeouts + +**Important Notes:** +- Activity tracking extends the **inactivity window** (60 minutes) +- Activity tracking does **NOT extend** the **absolute expiration** (24 hours) +- A token created 23 hours ago will expire in 1 hour, regardless of activity + +## Re-authentication Methods (Not Refresh) + +FamilySearch does not support refresh tokens. When a token expires, you must **re-authenticate** to obtain a completely new token. + +### Re-authentication vs. Refresh + +**❌ What FamilySearch does NOT have:** +```php +// This does NOT work with FamilySearch (no refresh tokens) +$newToken = $fs->refreshToken($refreshToken); // ❌ Not supported +``` + +**✅ What you must do instead:** +```php +// Re-authenticate to get a completely new token ✅ +$response = $fs->oauthPassword($username, $password); +$newToken = $response->data['access_token']; +``` + +### Password Grant Re-authentication + +**Use when:** You have stored user credentials (backend services, cron jobs) + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production' +]); + +// Initial authentication +$response = $fs->oauthPassword($username, $password); + +if ($response->statusCode === 200) { + echo "Authenticated successfully\n"; +} else { + die("Authentication failed\n"); +} + +// Later, when token expires... +if ($fs->isTokenExpired()) { + echo "Token expired, re-authenticating...\n"; + + // Re-authenticate with same credentials + $response = $fs->oauthPassword($username, $password); + + if ($response->statusCode === 200) { + echo "Re-authenticated successfully\n"; + // New token automatically stored in SDK + } +} +``` + +### Authorization Code Flow Re-authentication + +**Use when:** Building web applications with user interaction + +```php +// When token expires, redirect user to authorization +if ($fs->isTokenExpired()) { + $_SESSION['post_auth_redirect'] = $_SERVER['REQUEST_URI']; + header('Location: ' . $fs->oauthRedirectURL()); + exit; +} + +// In your OAuth callback handler: +// callback.php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'redirectUri' => 'https://myapp.com/callback.php' +]); + +$response = $fs->oauthResponse(); // Exchanges authorization code for token + +if ($response->statusCode === 200) { + // New token obtained and stored automatically + $redirect = $_SESSION['post_auth_redirect'] ?? '/'; + header('Location: ' . $redirect); + exit; +} +``` + +### Unauthenticated Session Re-authentication + +**Use when:** Accessing public data without user credentials + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production' +]); + +function ensureAuthenticated($fs) { + if ($fs->isTokenExpired() || !$fs->getAccessToken()) { + // Request new unauthenticated session + $response = $fs->post('/path/to/unauthenticated-endpoint', [ + 'body' => ['client_id' => $_ENV['FS_APP_KEY']] + ]); + + if ($response->statusCode === 200) { + $fs->setAccessToken($response->data['access_token']); + } + } +} + +// Use before making API requests +ensureAuthenticated($fs); +$response = $fs->get('/platform/collection'); +``` + +### Combining Re-authentication with Callback + +```php +$username = $_ENV['FS_USERNAME']; +$password = $_ENV['FS_PASSWORD']; + +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'replayFailedRequestsAfterAuth' => true, // Enable automatic replay (default) + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + if ($reason === 'expired') { + // Token expired - re-authenticate automatically + $authResponse = $fs->oauthPassword($username, $password); + + if ($authResponse->statusCode === 200) { + error_log('Automatically re-authenticated after token expiration'); + // SDK will automatically retry the original request + } else { + error_log('Re-authentication failed: ' . $authResponse->statusCode); + } + } + } +]); + +// Make requests normally - re-authentication happens transparently +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +// If token was expired: +// 1. Request fails with 401 +// 2. Callback re-authenticates +// 3. Request is automatically retried +// 4. Response is successful +if ($response->statusCode === 200) { + echo "Request succeeded\n"; + + if ($response->replayed ?? false) { + echo "Request was automatically retried after re-authentication\n"; + } +} +``` + +## Configuration Options + +### All Available Configuration Options + +```php +$fs = new FamilySearch([ + // Basic OAuth configuration + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', // 'integration', 'beta', or 'production' + 'redirectUri' => 'https://myapp.com/callback', + + // Session configuration + 'sessions' => true, // Enable automatic session storage (default: true) + 'sessionVariable' => 'FS_ACCESS_TOKEN', // Session variable name (default) + + // Encryption configuration (optional, recommended for production) + 'sessionEncryption' => true, // Encrypt session tokens (default: false) + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'], + + // Token expiration configuration + 'expirationWarningThreshold' => 300, // Seconds before expiration to warn (default: 300 = 5 minutes) + + // Authentication failure handling + 'onAuthenticationFailure' => function($response, $reason) { + // Your callback logic here + }, + 'replayFailedRequestsAfterAuth' => true, // Automatically retry after re-auth (default: true) + + // Other options + 'accessToken' => null, // Manually provide token (bypasses session) + 'maxThrottledRetries' => 5, // Retry limit for 429 responses (default: 5) + 'userAgent' => 'MyApp/1.0', // Additional user agent string + 'objects' => false, // Enable gedcomx-php objects (default: false) + 'pendingModifications' => ['feature-flag'] // Feature flags for pending API changes +]); +``` + +### Expiration Warning Threshold + +The `expirationWarningThreshold` determines when `isTokenExpired()` returns `true`: + +```php +// Default: 5 minutes +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'expirationWarningThreshold' => 300 +]); + +// Token expires at 10:00:00 +// At 09:55:00 (5 minutes before), isTokenExpired() returns true +// At 09:54:59, isTokenExpired() returns false + +// Custom: 10 minutes +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'expirationWarningThreshold' => 600 +]); + +// Token expires at 10:00:00 +// At 09:50:00 (10 minutes before), isTokenExpired() returns true + +// Exact expiration (no warning threshold) +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'expirationWarningThreshold' => 0 +]); + +// isTokenExpired() only returns true when token has actually expired +``` + +### Request Replay Configuration + +Control automatic request replay after re-authentication: + +```php +// Enabled (default): Failed requests automatically retried after re-auth +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'replayFailedRequestsAfterAuth' => true, // Default + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + // Re-authenticate + $fs->oauthPassword($username, $password); + // SDK automatically retries the original request + } +]); + +// Disabled: Manual retry required +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'replayFailedRequestsAfterAuth' => false, + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + // Re-authenticate + $fs->oauthPassword($username, $password); + // Application must manually retry the request + } +]); + +$response = $fs->get('/platform/users/current'); +if ($response->statusCode === 401) { + // Callback was invoked and re-authenticated, but no automatic retry + // Manually retry the request + $response = $fs->get('/platform/users/current'); +} +``` + +## Request Replay Behavior + +When automatic replay is enabled (default), failed requests are transparently retried after successful re-authentication. + +### How Replay Works + +1. **Original request fails with 401** +2. **Callback invoked** with response and reason ('expired' or 'invalid') +3. **Callback re-authenticates** (calls `oauthPassword()` or `setAccessToken()`) +4. **SDK detects token change** (compares token before/after callback) +5. **Request automatically retried** with new token +6. **Successful response returned** (or second 401 if new token also fails) + +### Replay Metadata + +Responses from replayed requests include additional metadata: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + $fs->oauthPassword($username, $password); + } +]); + +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +// Check if request was replayed +if ($response->replayed ?? false) { + echo "This request was automatically retried after re-authentication\n"; + + // Access the original 401 response + echo "Original status: {$response->originalResponse->statusCode}\n"; + echo "New status: {$response->statusCode}\n"; +} +``` + +### Replay Safety: Single Retry Only + +To prevent infinite loops, replay only happens **once per request**: + +```php +$callbackCount = 0; + +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, &$callbackCount) { + $callbackCount++; + + // Set another invalid token (this is intentionally wrong for demonstration) + $fs->setAccessToken('still-invalid-token-' . $callbackCount); + } +]); + +$response = $fs->get('/platform/users/current'); + +// Callback is invoked twice: +// 1. For original 401 +// 2. For replay 401 +// But no third retry occurs (prevents infinite loop) +echo "Callback invoked: {$callbackCount} times\n"; // Output: 2 +echo "Final status: {$response->statusCode}\n"; // Output: 401 +``` + +### When Replay Occurs + +Replay happens when **ALL** of these conditions are met: + +1. ✅ Response status is 401 +2. ✅ `onAuthenticationFailure` callback is configured +3. ✅ Callback obtains a new token (token changes) +4. ✅ `replayFailedRequestsAfterAuth` is `true` (default) +5. ✅ This is not already a retry (prevents loops) + +### When Replay Does NOT Occur + +No replay in these cases: + +- ❌ No callback configured +- ❌ Callback doesn't obtain new token +- ❌ `replayFailedRequestsAfterAuth` is `false` +- ❌ Response status is not 401 (other errors) +- ❌ This is already a retry attempt + +## Backward Compatibility + +The token expiration features are **100% backward compatible**. Existing code continues to work without any changes. + +### No Changes Required + +```php +// Existing code works unchanged +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production' +]); + +$fs->oauthPassword($username, $password); +$token = $fs->getAccessToken(); // Still returns string +$response = $fs->get('/platform/users/current'); +``` + +### Opt-In Enhancement + +New features are opt-in and don't affect existing behavior: + +```php +// Old way (still works) +$token = $fs->getAccessToken(); +if (is_string($token)) { + echo "Token: $token\n"; // ✅ Works +} + +// New way (opt-in) +$tokenInfo = $fs->getAccessToken(true); // Pass true for detailed info +if (is_array($tokenInfo)) { + echo "Token: {$tokenInfo['token']}\n"; + echo "Expires at: {$tokenInfo['expires_at']}\n"; +} +``` + +### Session Format Migration + +The SDK automatically migrates old session formats: + +```php +// Old session format (plaintext token string) +$_SESSION['FS_ACCESS_TOKEN'] = 'abc123'; + +// SDK automatically detects and migrates: +$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); +$token = $fs->getAccessToken(); // ✅ Works + +// Next OAuth authentication stores new format with metadata +$fs->oauthPassword($username, $password); +// Session now contains: {"token":"xyz789","created":...,"last_activity":...} +``` + +### Default Behavior + +All new features have sensible defaults that maintain existing behavior: + +| Feature | Default | Impact | +|---------|---------|--------| +| `expirationWarningThreshold` | 300 seconds | Conservative threshold | +| `onAuthenticationFailure` | `null` | No callback (existing behavior) | +| `replayFailedRequestsAfterAuth` | `true` | Replay enabled (if callback configured) | +| `getAccessToken()` | Returns string | Backward compatible | +| Token tracking | Automatic | Transparent (no code changes needed) | + +## Migration Guide + +### Adding Expiration Tracking to Existing Code + +**Step 1: Assess Current Implementation** + +```php +// Current code (before migration) +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production' +]); + +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +if ($response->statusCode === 401) { + // Manual re-authentication + $fs->oauthPassword($username, $password); + + // Manual retry + $response = $fs->get('/platform/tree/persons/PPPP-PPP'); +} +``` + +**Step 2: Add Callback for Automatic Handling** + +```php +// After migration (automatic handling) +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + if ($reason === 'expired') { + $fs->oauthPassword($username, $password); + // Automatic retry happens (no manual retry needed) + } + } +]); + +// Simplified code - no manual 401 handling needed +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +// Request succeeds even if token was expired (automatic re-auth + retry) +``` + +**Step 3 (Optional): Add Proactive Checking** + +```php +// Add proactive expiration checking for better UX +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'expirationWarningThreshold' => 600, // Check 10 minutes before expiration + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + $fs->oauthPassword($username, $password); + } +]); + +// Check before making requests +if ($fs->isTokenExpired()) { + echo "Token is expiring soon, re-authenticating proactively...\n"; + $fs->oauthPassword($username, $password); +} + +// Make requests +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +``` + +### Migration Checklist + +- [ ] **Review current 401 handling** - Identify where you manually handle authentication failures +- [ ] **Add callback** - Implement `onAuthenticationFailure` callback with re-authentication logic +- [ ] **Remove manual 401 checks** - Let callback handle 401s automatically (optional - both approaches work) +- [ ] **Test re-authentication** - Verify callback re-authenticates correctly +- [ ] **Add proactive checking** - Use `isTokenExpired()` before long-running operations (optional) +- [ ] **Configure threshold** - Adjust `expirationWarningThreshold` for your use case (optional) +- [ ] **Enable encryption** - Add `sessionEncryption` for production (recommended, see SECURITY.md) + +### Zero-Downtime Migration + +The SDK supports gradual migration without downtime: + +```php +// Phase 1: Keep existing 401 handling, add callback for logging +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'replayFailedRequestsAfterAuth' => false, // Disable replay initially + 'onAuthenticationFailure' => function($response, $reason) { + // Just log for now, don't re-authenticate yet + error_log("Would have re-authenticated: {$reason}"); + } +]); + +// Existing manual handling still works +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +if ($response->statusCode === 401) { + $fs->oauthPassword($username, $password); + $response = $fs->get('/platform/tree/persons/PPPP-PPP'); +} + +// Phase 2: Enable automatic re-authentication in callback +// (Update callback to actually re-authenticate) + +// Phase 3: Enable automatic replay +// (Set replayFailedRequestsAfterAuth to true) + +// Phase 4: Remove manual 401 handling +// (Callback handles everything automatically) +``` + +## Addressing Issue #2 (2016) + +This token expiration handling feature directly addresses [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) opened in 2016. + +### The Original Problem + +Before this enhancement (issue opened 2016-09-09): + +```php +// Developers had to manually detect 401s and re-authenticate +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); + +if ($response->statusCode === 401) { + // Manual detection required + // Manual re-authentication required + // Manual retry required + $fs->oauthPassword($username, $password); + $response = $fs->get('/platform/tree/persons/PPPP-PPP'); +} + +// Problems: +// - No visibility into token expiration +// - No proactive handling possible +// - Repetitive boilerplate code +// - No automatic retry mechanism +// - No distinction between expired vs. invalid tokens +``` + +### The Solution (2024) + +After this enhancement (versions 1.4.0 - 1.6.0): + +```php +// Three flexible approaches with automatic handling +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + $fs->oauthPassword($username, $password); + // Automatic retry - no manual code needed + } +]); + +// Simplified code - no manual 401 handling needed +$response = $fs->get('/platform/tree/persons/PPPP-PPP'); +// Works transparently even if token expired + +// Benefits: +// ✅ Proactive expiration checking (isTokenExpired) +// ✅ Expiration time visibility (getTokenExpirationTime) +// ✅ Automatic callback-based re-authentication +// ✅ Automatic request replay +// ✅ Clear distinction: 'expired' vs 'invalid' +// ✅ Activity tracking (60-minute window) +// ✅ Absolute expiration tracking (24-hour window) +``` + +### Why It Took 8 Years + +The original issue required understanding FamilySearch's unique token behavior: + +1. **No `expires_in` field** - Required client-side tracking implementation +2. **Dual expiration conditions** - Both 24-hour absolute AND 60-minute inactivity +3. **Activity resets timer** - Each API call extends the 60-minute window +4. **No refresh tokens** - Must re-authenticate, not refresh +5. **Backward compatibility** - Existing implementations must continue working + +This enhancement was carefully designed to address all these requirements while maintaining full backward compatibility. + +## Examples Summary + +### 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) { + $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']); +``` + +### Complete Working Example + +```php + $_ENV['FS_APP_KEY'], + 'environment' => 'production', + 'sessions' => true, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'], + 'expirationWarningThreshold' => 300, // Warn 5 minutes before expiration + 'replayFailedRequestsAfterAuth' => true, // Enable automatic replay + 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { + error_log("Authentication failed: {$reason}"); + + if ($reason === 'expired') { + // Re-authenticate automatically + $authResponse = $fs->oauthPassword($username, $password); + + if ($authResponse->statusCode === 200) { + error_log('Successfully re-authenticated'); + } else { + error_log('Re-authentication failed'); + throw new Exception('Unable to re-authenticate'); + } + } else { + // Token is invalid (possibly revoked) + throw new Exception('Authentication token is invalid'); + } + } +]); + +// Initial authentication +$response = $fs->oauthPassword($username, $password); +if ($response->statusCode !== 200) { + die("Initial authentication failed\n"); +} + +// Display token information +$tokenInfo = $fs->getAccessToken(true); +echo "Authenticated successfully\n"; +echo "Token expires: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']) . "\n"; + +// Make API requests - re-authentication happens automatically if needed +$response = $fs->get('/platform/users/current'); +if ($response->statusCode === 200) { + echo "Request successful\n"; + + if ($response->replayed ?? false) { + echo "Note: Request was automatically retried after token expiration\n"; + } +} + +// Check expiration status +if ($fs->isTokenExpired()) { + echo "Warning: Token is expired or expiring soon\n"; +} else { + $timeRemaining = $fs->getTokenExpirationTime() - time(); + $minutesRemaining = floor($timeRemaining / 60); + echo "Token valid for {$minutesRemaining} more minutes\n"; +} +``` + +## Additional Resources + +- **Main README**: [README.md](../README.md) - Getting started and basic usage +- **Security Guide**: [SECURITY.md](../SECURITY.md) - Session encryption and security best practices +- **Testing Guide**: [TESTING.md](../TESTING.md) - Writing tests for your application +- **FamilySearch API Docs**: https://developers.familysearch.org/ +- **Issue #2 (2016)**: https://github.com/FamilySearch/fs-php-lite/issues/2 + +## Support + +For questions, issues, or feature requests, please: +- Open an issue on GitHub: https://github.com/FamilySearch/fs-php-lite/issues +- Check the FamilySearch Developer Forum: https://developers.familysearch.org/ + +--- + +**Version Requirements**: Token expiration tracking requires fs-php-lite v1.4.0 or higher. + +- v1.4.0: Token expiration tracking (`isTokenExpired()`, `getTokenExpirationTime()`) +- v1.5.0: Authentication failure callback (`onAuthenticationFailure`) +- v1.6.0: Automatic request replay (`replayFailedRequestsAfterAuth`) diff --git a/src/FamilySearch.php b/src/FamilySearch.php index 408169b..45da15a 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 @@ -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); + } +} From 16b08283d6c0bf1e3a011f4469d3ef09377313ca Mon Sep 17 00:00:00 2001 From: neil_pack Date: Fri, 21 Aug 2026 09:54:33 -0600 Subject: [PATCH 2/4] Cleaned up documentation in TOKEN_EXPIRATION.md --- README.md | 12 +- docs/TOKEN_EXPIRATION.md | 1083 ++------------------------------------ 2 files changed, 47 insertions(+), 1048 deletions(-) diff --git a/README.md b/README.md index 4317c51..fdb85c1 100644 --- a/README.md +++ b/README.md @@ -388,17 +388,7 @@ echo "Time remaining: {$minutesRemaining} minutes\n"; ### Complete Documentation -For comprehensive documentation including: -- FamilySearch token behavior details -- Complete working examples -- Migration guide from manual 401 handling -- Configuration options reference -- Request replay behavior -- Activity tracking details - -See **[docs/TOKEN_EXPIRATION.md](docs/TOKEN_EXPIRATION.md)** - -This feature addresses [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) (opened 2016), which requested automatic token expiration handling and re-authentication support. +For detailed documentation including additional examples, configuration options, and request replay behavior, see **[TOKEN_EXPIRATION.md](docs/TOKEN_EXPIRATION.md)** ## Serialization with gedcomx-php diff --git a/docs/TOKEN_EXPIRATION.md b/docs/TOKEN_EXPIRATION.md index 87d450a..9446c19 100644 --- a/docs/TOKEN_EXPIRATION.md +++ b/docs/TOKEN_EXPIRATION.md @@ -2,61 +2,30 @@ ## Overview -The FamilySearch PHP Lite SDK provides comprehensive token expiration tracking and automatic re-authentication capabilities, addressing [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) from 2016. Prior to this enhancement, developers had to manually detect 401 responses and implement their own re-authentication logic. The SDK now provides three flexible approaches to handle token expiration transparently. +The FamilySearch PHP Lite SDK provides three flexible approaches to handle token expiration: -## FamilySearch Token Behavior - -Before diving into implementation approaches, it's critical to understand how FamilySearch access tokens work: +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 -### Token Lifetime Characteristics +## 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 -### Key Behaviors - -- ✅ **Each successful API call resets the 60-minute inactivity timer** - - Making an API call within 60 minutes keeps the token alive - - The token can remain valid for the full 24 hours if used regularly - -- ❌ **No refresh tokens available** - - FamilySearch does not support OAuth refresh token grants - - When a token expires, you must re-authenticate to obtain a completely new token - - This is different from many OAuth providers that support refresh tokens - -- 🔍 **No `expires_in` field in OAuth responses** - - The FamilySearch API returns: `{"access_token": "...", "token_type": "family_search"}` - - Unlike standard OAuth, there is no `expires_in` field - - The SDK tracks expiration **client-side** using timestamps - -- 🤝 **401 responses don't distinguish expiration causes** - - The API returns 401 for: expired tokens, invalid tokens, revoked tokens, missing tokens - - The SDK uses client-side tracking to determine the likely reason - -### Token Lifetime Calculation - -The SDK calculates token expiration as: - -```php -$expirationTime = min( - $tokenCreationTime + 86400, // 24 hours from creation - $tokenLastActivityTime + 3600 // 60 minutes from last activity -); -``` - -**Examples:** - -- **Fresh token, no activity**: Expires in 60 minutes (inactivity limit) -- **Token created 23 hours ago, used 30 minutes ago**: Expires in 1 hour (absolute limit) -- **Token created 2 hours ago, used 10 minutes ago**: Expires in 50 minutes (inactivity limit) +**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 -The SDK provides methods to check token expiration **before** making API requests, allowing you to re-authenticate proactively. +Check token expiration **before** making API requests to re-authenticate proactively. -### Basic Expiration Check +### Basic Usage ```php $fs = new FamilySearch([ @@ -67,98 +36,26 @@ $fs = new FamilySearch([ // Check if token is expired or expiring soon if ($fs->isTokenExpired()) { - // Token is expired or within 5 minutes of expiration - // Re-authenticate before making requests $fs->oauthPassword($username, $password); } -// Now safe to make API requests $response = $fs->get('/platform/tree/persons/PPPP-PPP'); ``` -### Display Expiration Time to User +### Display Expiration Time ```php $expirationTime = $fs->getTokenExpirationTime(); if ($expirationTime) { - $timeUntilExpiration = $expirationTime - time(); - $minutesRemaining = floor($timeUntilExpiration / 60); - + $minutesRemaining = floor(($expirationTime - time()) / 60); echo "Your session will expire in {$minutesRemaining} minutes\n"; - - if ($minutesRemaining < 10) { - echo "Warning: Your session is about to expire!\n"; - } -} -``` - -### Near-Expiration Detection - -```php -// Configure a custom warning threshold (e.g., 10 minutes) -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'expirationWarningThreshold' => 600 // 10 minutes -]); - -if ($fs->isTokenExpired()) { - // Token is expired OR within 10 minutes of expiration - echo "Time to re-authenticate!\n"; - $fs->oauthPassword($username, $password); -} -``` - -### Expiration Calculation Details - -The SDK determines expiration based on **the sooner** of the two limits: - -```php -// Get detailed token information -$tokenInfo = $fs->getAccessToken(true); - -echo "Token created: " . date('Y-m-d H:i:s', $tokenInfo['created']) . "\n"; -echo "Last activity: " . date('Y-m-d H:i:s', $tokenInfo['last_activity']) . "\n"; -echo "Expires at: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']) . "\n"; -echo "Is expired: " . ($tokenInfo['is_expired'] ? 'Yes' : 'No') . "\n"; - -// Calculate which expiration limit applies -$absoluteExpiration = $tokenInfo['created'] + 86400; // 24 hours -$inactivityExpiration = $tokenInfo['last_activity'] + 3600; // 60 minutes -$actualExpiration = min($absoluteExpiration, $inactivityExpiration); - -if ($actualExpiration === $absoluteExpiration) { - echo "Token will expire due to 24-hour absolute limit\n"; -} else { - echo "Token will expire due to 60-minute inactivity limit\n"; } ``` ## Approach 2: Authentication Failure Callback -The SDK can automatically detect 401 responses and invoke a callback, allowing you to handle re-authentication transparently. - -### Basic Callback Configuration - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'onAuthenticationFailure' => function($response, $reason) { - // Called when a 401 response is received - // $response: Full response object with statusCode 401 - // $reason: 'expired' or 'invalid' - - error_log("Authentication failed: {$reason}"); - - if ($reason === 'expired') { - // Token expired - re-authenticate - } else { - // Token is invalid - may have been revoked - } - } -]); -``` +Automatically detect and handle 401 responses with a callback. ### Automatic Re-authentication (Password Grant) @@ -173,20 +70,10 @@ $fs = new FamilySearch([ 'environment' => 'production', 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { if ($reason === 'expired') { - error_log('Token expired, re-authenticating automatically...'); - // Re-authenticate with password grant - $authResponse = $fs->oauthPassword($username, $password); - - if ($authResponse->statusCode === 200) { - error_log('Re-authentication successful'); - } else { - error_log('Re-authentication failed'); - // Handle re-authentication failure - } + $fs->oauthPassword($username, $password); } else { - error_log('Token invalid: ' . $reason); - // Token was revoked or is otherwise invalid + // Token was revoked or is invalid throw new Exception('Authentication token is invalid'); } } @@ -195,14 +82,9 @@ $fs = new FamilySearch([ // Make requests normally - re-authentication happens automatically $response = $fs->get('/platform/tree/persons/PPPP-PPP'); -// With automatic replay enabled (default), the request succeeds transparently -if ($response->statusCode === 200) { - echo "Request succeeded!\n"; - - // Check if the request was replayed after re-authentication - if ($response->replayed ?? false) { - echo "Note: Request was automatically retried after re-authentication\n"; - } +// Check if the request was replayed after re-authentication +if ($response->replayed ?? false) { + echo "Request was automatically retried after re-authentication\n"; } ``` @@ -216,113 +98,31 @@ $fs = new FamilySearch([ 'redirectUri' => 'https://myapp.com/oauth/callback', 'environment' => 'production', 'onAuthenticationFailure' => function($response, $reason) use (&$fs) { - // Store the original request URL in session for post-login redirect $_SESSION['original_request'] = $_SERVER['REQUEST_URI']; - - if ($reason === 'expired') { - error_log('Session expired, redirecting to login...'); - } else { - error_log('Authentication invalid, redirecting to login...'); - } - - // Redirect user to FamilySearch OAuth authorization page header('Location: ' . $fs->oauthRedirectURL()); exit; } ]); - -// Your OAuth callback handler: -// oauth/callback.php -$fs->oauthResponse(); // Exchanges code for token - -// Redirect back to original request -$originalRequest = $_SESSION['original_request'] ?? '/'; -header('Location: ' . $originalRequest); -exit; -``` - -### Unauthenticated Session Renewal - -**Best for:** Public API access, read-only operations - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'onAuthenticationFailure' => function($response, $reason) use (&$fs) { - if ($reason === 'expired') { - error_log('Unauthenticated session expired, requesting new session...'); - - // Request a new unauthenticated session token - // Note: This endpoint may vary - check FamilySearch API docs - $response = $fs->post('/platform/authentication/unauthenticated-session', [ - 'body' => ['client_id' => $_ENV['FS_APP_KEY']] - ]); - - if ($response->statusCode === 200) { - $fs->setAccessToken($response->data['access_token']); - error_log('New unauthenticated session obtained'); - } - } - } -]); ``` -### Understanding Failure Reasons - -The callback receives a `$reason` parameter indicating why authentication failed: - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'onAuthenticationFailure' => function($response, $reason) { - // $reason can be: - // - 'expired': SDK's client-side tracking indicates token should be expired - // - 'invalid': 401 occurred but token shouldn't be expired per tracking - - switch ($reason) { - case 'expired': - // Token expired due to: - // - 24 hours passed since creation, OR - // - 60 minutes passed since last activity - // - // Action: Re-authenticate to get new token - error_log('Token expired normally - re-authenticating'); - break; - - case 'invalid': - // Token is invalid but not expired according to tracking. - // Possible causes: - // - Token was manually revoked - // - Token was never valid (wrong token set) - // - Server rejected token for other reasons - // - Clock skew between client and server - // - // Action: Depends on your application logic - error_log('Token invalid - may have been revoked'); - break; - } - } -]); -``` +### Callback Reason Parameter -**Important:** The FamilySearch API always returns 401 for any authentication failure. The SDK uses **client-side token tracking** to determine whether the failure was due to expiration (`'expired'`) or another reason (`'invalid'`). This is the SDK's best guess based on timestamps, not information from the API. +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 -The SDK provides enhanced methods to retrieve detailed token information for manual management. +Access detailed token metadata for custom management logic. -### Get Detailed Token Information +### Get Token Information ```php -// Backward compatible: getAccessToken() returns string +// Standard: getAccessToken() returns string $token = $fs->getAccessToken(); -echo "Token: {$token}\n"; // String: "abc123..." // Enhanced: getAccessToken(true) returns array with metadata $tokenInfo = $fs->getAccessToken(true); - -print_r($tokenInfo); /* Array ( [token] => abc123... @@ -333,762 +133,54 @@ Array ( ) */ -// Use detailed info for custom logic if ($tokenInfo['is_expired']) { echo "Token is expired or expiring soon\n"; -} else { - $timeRemaining = $tokenInfo['expires_at'] - time(); - echo "Token valid for {$timeRemaining} more seconds\n"; } ``` -### Manual Token Management with `setAccessToken()` +### Manual Token Management ```php -// When manually setting a token, the SDK tracks expiration automatically $fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); -// Option 1: Set token without expiration info (common with FamilySearch) -// SDK treats it as freshly issued: creation=now, last_activity=now +// Set token without expiration info (treated as freshly issued) $fs->setAccessToken('your-access-token-here'); -// Option 2: Set token with known remaining lifetime -// Use this when restoring a token from storage and you know how long it has left -$fs->setAccessToken('your-access-token-here', 3600); // Token has 1 hour remaining - -// The SDK calculates: -// - Creation time: now - (86400 - $expiresIn) -// - Last activity: now -// - Expiration: min(creation + 24hrs, last_activity + 60min) -``` - -### Custom Token Storage and Retrieval - -```php -// Example: Store token in database with expiration info -class TokenStorage { - public static function saveToken($token, $created, $lastActivity) { - $db = getDatabase(); - $db->query( - "INSERT INTO tokens (token, created_at, last_activity) VALUES (?, ?, ?)", - [$token, $created, $lastActivity] - ); - } - - public static function loadToken() { - $db = getDatabase(); - $row = $db->query("SELECT * FROM tokens WHERE user_id = ? ORDER BY id DESC LIMIT 1"); - - if ($row) { - // Calculate remaining time - $created = strtotime($row['created_at']); - $lastActivity = strtotime($row['last_activity']); - $absoluteExpiration = $created + 86400; - $inactivityExpiration = $lastActivity + 3600; - $expirationTime = min($absoluteExpiration, $inactivityExpiration); - $remainingTime = $expirationTime - time(); - - if ($remainingTime > 0) { - return [ - 'token' => $row['token'], - 'expires_in' => $remainingTime - ]; - } - } - - return null; - } -} - -// Use custom storage with SDK -$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); - -$tokenData = TokenStorage::loadToken(); -if ($tokenData) { - // Restore token with remaining lifetime - $fs->setAccessToken($tokenData['token'], $tokenData['expires_in']); -} else { - // No valid token - authenticate - $response = $fs->oauthPassword($username, $password); - - // Save new token - $tokenInfo = $fs->getAccessToken(true); - TokenStorage::saveToken( - $tokenInfo['token'], - $tokenInfo['created'], - $tokenInfo['last_activity'] - ); -} +// Set token with known remaining lifetime +$fs->setAccessToken('your-access-token-here', 3600); // 1 hour remaining ``` ## Activity Tracking -The SDK automatically tracks API activity to manage the 60-minute inactivity window. - -### Automatic Activity Tracking - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -// Authenticate -$fs->oauthPassword($username, $password); - -// Each successful API call resets the 60-minute inactivity timer -$fs->get('/platform/tree/persons/PPPP-PPP'); // Last activity updated -sleep(1800); // Wait 30 minutes -$fs->get('/platform/collection'); // Last activity updated again -sleep(1800); // Wait another 30 minutes -$fs->get('/platform/users/current'); // Last activity updated again - -// Token remains valid because we used it within 60 minutes each time -// Without this activity, token would expire after 60 minutes of inactivity -``` - -### Check Time Until Expiration +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. -```php -$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); -$fs->oauthPassword($username, $password); - -function checkExpiration($fs) { - $expirationTime = $fs->getTokenExpirationTime(); - $now = time(); - - $secondsRemaining = $expirationTime - $now; - $minutesRemaining = floor($secondsRemaining / 60); - - echo "Token expires in: {$minutesRemaining} minutes\n"; - - // Get details about which limit applies - $tokenInfo = $fs->getAccessToken(true); - $absoluteExpiration = $tokenInfo['created'] + 86400; - $inactivityExpiration = $tokenInfo['last_activity'] + 3600; - - if ($expirationTime === $absoluteExpiration) { - echo "Expiring due to: 24-hour absolute limit\n"; - $hoursFromCreation = floor((time() - $tokenInfo['created']) / 3600); - echo "Token age: {$hoursFromCreation} hours\n"; - } else { - echo "Expiring due to: 60-minute inactivity limit\n"; - $minutesSinceActivity = floor((time() - $tokenInfo['last_activity']) / 60); - echo "Minutes since last activity: {$minutesSinceActivity}\n"; - } -} - -checkExpiration($fs); - -// Make an API call -$fs->get('/platform/users/current'); - -checkExpiration($fs); // Expiration extended due to activity -``` - -### Activity Tracking Behavior - -**What updates last activity:** -- ✅ Successful API responses (2xx status codes) -- ✅ Redirect responses (3xx status codes) - -**What does NOT update last activity:** -- ❌ 401 Unauthorized responses (authentication failures) -- ❌ 4xx Client errors (except redirects) -- ❌ 5xx Server errors -- ❌ Network failures or timeouts - -**Important Notes:** -- Activity tracking extends the **inactivity window** (60 minutes) -- Activity tracking does **NOT extend** the **absolute expiration** (24 hours) -- A token created 23 hours ago will expire in 1 hour, regardless of activity - -## Re-authentication Methods (Not Refresh) - -FamilySearch does not support refresh tokens. When a token expires, you must **re-authenticate** to obtain a completely new token. - -### Re-authentication vs. Refresh - -**❌ What FamilySearch does NOT have:** -```php -// This does NOT work with FamilySearch (no refresh tokens) -$newToken = $fs->refreshToken($refreshToken); // ❌ Not supported -``` - -**✅ What you must do instead:** -```php -// Re-authenticate to get a completely new token ✅ -$response = $fs->oauthPassword($username, $password); -$newToken = $response->data['access_token']; -``` - -### Password Grant Re-authentication - -**Use when:** You have stored user credentials (backend services, cron jobs) - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -// Initial authentication -$response = $fs->oauthPassword($username, $password); - -if ($response->statusCode === 200) { - echo "Authenticated successfully\n"; -} else { - die("Authentication failed\n"); -} - -// Later, when token expires... -if ($fs->isTokenExpired()) { - echo "Token expired, re-authenticating...\n"; - - // Re-authenticate with same credentials - $response = $fs->oauthPassword($username, $password); - - if ($response->statusCode === 200) { - echo "Re-authenticated successfully\n"; - // New token automatically stored in SDK - } -} -``` - -### Authorization Code Flow Re-authentication - -**Use when:** Building web applications with user interaction - -```php -// When token expires, redirect user to authorization -if ($fs->isTokenExpired()) { - $_SESSION['post_auth_redirect'] = $_SERVER['REQUEST_URI']; - header('Location: ' . $fs->oauthRedirectURL()); - exit; -} - -// In your OAuth callback handler: -// callback.php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'redirectUri' => 'https://myapp.com/callback.php' -]); - -$response = $fs->oauthResponse(); // Exchanges authorization code for token - -if ($response->statusCode === 200) { - // New token obtained and stored automatically - $redirect = $_SESSION['post_auth_redirect'] ?? '/'; - header('Location: ' . $redirect); - exit; -} -``` - -### Unauthenticated Session Re-authentication - -**Use when:** Accessing public data without user credentials - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -function ensureAuthenticated($fs) { - if ($fs->isTokenExpired() || !$fs->getAccessToken()) { - // Request new unauthenticated session - $response = $fs->post('/path/to/unauthenticated-endpoint', [ - 'body' => ['client_id' => $_ENV['FS_APP_KEY']] - ]); - - if ($response->statusCode === 200) { - $fs->setAccessToken($response->data['access_token']); - } - } -} - -// Use before making API requests -ensureAuthenticated($fs); -$response = $fs->get('/platform/collection'); -``` - -### Combining Re-authentication with Callback - -```php -$username = $_ENV['FS_USERNAME']; -$password = $_ENV['FS_PASSWORD']; - -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'replayFailedRequestsAfterAuth' => true, // Enable automatic replay (default) - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - if ($reason === 'expired') { - // Token expired - re-authenticate automatically - $authResponse = $fs->oauthPassword($username, $password); - - if ($authResponse->statusCode === 200) { - error_log('Automatically re-authenticated after token expiration'); - // SDK will automatically retry the original request - } else { - error_log('Re-authentication failed: ' . $authResponse->statusCode); - } - } - } -]); - -// Make requests normally - re-authentication happens transparently -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); - -// If token was expired: -// 1. Request fails with 401 -// 2. Callback re-authenticates -// 3. Request is automatically retried -// 4. Response is successful -if ($response->statusCode === 200) { - echo "Request succeeded\n"; - - if ($response->replayed ?? false) { - echo "Request was automatically retried after re-authentication\n"; - } -} -``` ## Configuration Options -### All Available Configuration Options +### Key Token Expiration Options ```php $fs = new FamilySearch([ - // Basic OAuth configuration 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', // 'integration', 'beta', or 'production' - 'redirectUri' => 'https://myapp.com/callback', - - // Session configuration - 'sessions' => true, // Enable automatic session storage (default: true) - 'sessionVariable' => 'FS_ACCESS_TOKEN', // Session variable name (default) - - // Encryption configuration (optional, recommended for production) - 'sessionEncryption' => true, // Encrypt session tokens (default: false) - 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'], + 'environment' => 'production', - // Token expiration configuration - 'expirationWarningThreshold' => 300, // Seconds before expiration to warn (default: 300 = 5 minutes) + // 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: true) - - // Other options - 'accessToken' => null, // Manually provide token (bypasses session) - 'maxThrottledRetries' => 5, // Retry limit for 429 responses (default: 5) - 'userAgent' => 'MyApp/1.0', // Additional user agent string - 'objects' => false, // Enable gedcomx-php objects (default: false) - 'pendingModifications' => ['feature-flag'] // Feature flags for pending API changes + 'replayFailedRequestsAfterAuth' => true, // Automatically retry after re-auth (default) ]); ``` -### Expiration Warning Threshold +## Request Replay -The `expirationWarningThreshold` determines when `isTokenExpired()` returns `true`: +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. -```php -// Default: 5 minutes -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'expirationWarningThreshold' => 300 -]); - -// Token expires at 10:00:00 -// At 09:55:00 (5 minutes before), isTokenExpired() returns true -// At 09:54:59, isTokenExpired() returns false - -// Custom: 10 minutes -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'expirationWarningThreshold' => 600 -]); -// Token expires at 10:00:00 -// At 09:50:00 (10 minutes before), isTokenExpired() returns true - -// Exact expiration (no warning threshold) -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'expirationWarningThreshold' => 0 -]); - -// isTokenExpired() only returns true when token has actually expired -``` - -### Request Replay Configuration - -Control automatic request replay after re-authentication: - -```php -// Enabled (default): Failed requests automatically retried after re-auth -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'replayFailedRequestsAfterAuth' => true, // Default - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - // Re-authenticate - $fs->oauthPassword($username, $password); - // SDK automatically retries the original request - } -]); - -// Disabled: Manual retry required -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'replayFailedRequestsAfterAuth' => false, - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - // Re-authenticate - $fs->oauthPassword($username, $password); - // Application must manually retry the request - } -]); - -$response = $fs->get('/platform/users/current'); -if ($response->statusCode === 401) { - // Callback was invoked and re-authenticated, but no automatic retry - // Manually retry the request - $response = $fs->get('/platform/users/current'); -} -``` - -## Request Replay Behavior - -When automatic replay is enabled (default), failed requests are transparently retried after successful re-authentication. - -### How Replay Works - -1. **Original request fails with 401** -2. **Callback invoked** with response and reason ('expired' or 'invalid') -3. **Callback re-authenticates** (calls `oauthPassword()` or `setAccessToken()`) -4. **SDK detects token change** (compares token before/after callback) -5. **Request automatically retried** with new token -6. **Successful response returned** (or second 401 if new token also fails) - -### Replay Metadata - -Responses from replayed requests include additional metadata: - -```php -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - $fs->oauthPassword($username, $password); - } -]); - -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); - -// Check if request was replayed -if ($response->replayed ?? false) { - echo "This request was automatically retried after re-authentication\n"; - - // Access the original 401 response - echo "Original status: {$response->originalResponse->statusCode}\n"; - echo "New status: {$response->statusCode}\n"; -} -``` - -### Replay Safety: Single Retry Only - -To prevent infinite loops, replay only happens **once per request**: - -```php -$callbackCount = 0; - -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, &$callbackCount) { - $callbackCount++; - - // Set another invalid token (this is intentionally wrong for demonstration) - $fs->setAccessToken('still-invalid-token-' . $callbackCount); - } -]); - -$response = $fs->get('/platform/users/current'); - -// Callback is invoked twice: -// 1. For original 401 -// 2. For replay 401 -// But no third retry occurs (prevents infinite loop) -echo "Callback invoked: {$callbackCount} times\n"; // Output: 2 -echo "Final status: {$response->statusCode}\n"; // Output: 401 -``` - -### When Replay Occurs - -Replay happens when **ALL** of these conditions are met: - -1. ✅ Response status is 401 -2. ✅ `onAuthenticationFailure` callback is configured -3. ✅ Callback obtains a new token (token changes) -4. ✅ `replayFailedRequestsAfterAuth` is `true` (default) -5. ✅ This is not already a retry (prevents loops) - -### When Replay Does NOT Occur - -No replay in these cases: - -- ❌ No callback configured -- ❌ Callback doesn't obtain new token -- ❌ `replayFailedRequestsAfterAuth` is `false` -- ❌ Response status is not 401 (other errors) -- ❌ This is already a retry attempt - -## Backward Compatibility - -The token expiration features are **100% backward compatible**. Existing code continues to work without any changes. - -### No Changes Required - -```php -// Existing code works unchanged -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -$fs->oauthPassword($username, $password); -$token = $fs->getAccessToken(); // Still returns string -$response = $fs->get('/platform/users/current'); -``` - -### Opt-In Enhancement - -New features are opt-in and don't affect existing behavior: - -```php -// Old way (still works) -$token = $fs->getAccessToken(); -if (is_string($token)) { - echo "Token: $token\n"; // ✅ Works -} - -// New way (opt-in) -$tokenInfo = $fs->getAccessToken(true); // Pass true for detailed info -if (is_array($tokenInfo)) { - echo "Token: {$tokenInfo['token']}\n"; - echo "Expires at: {$tokenInfo['expires_at']}\n"; -} -``` - -### Session Format Migration - -The SDK automatically migrates old session formats: - -```php -// Old session format (plaintext token string) -$_SESSION['FS_ACCESS_TOKEN'] = 'abc123'; - -// SDK automatically detects and migrates: -$fs = new FamilySearch(['appKey' => $_ENV['FS_APP_KEY']]); -$token = $fs->getAccessToken(); // ✅ Works - -// Next OAuth authentication stores new format with metadata -$fs->oauthPassword($username, $password); -// Session now contains: {"token":"xyz789","created":...,"last_activity":...} -``` - -### Default Behavior - -All new features have sensible defaults that maintain existing behavior: - -| Feature | Default | Impact | -|---------|---------|--------| -| `expirationWarningThreshold` | 300 seconds | Conservative threshold | -| `onAuthenticationFailure` | `null` | No callback (existing behavior) | -| `replayFailedRequestsAfterAuth` | `true` | Replay enabled (if callback configured) | -| `getAccessToken()` | Returns string | Backward compatible | -| Token tracking | Automatic | Transparent (no code changes needed) | - -## Migration Guide - -### Adding Expiration Tracking to Existing Code - -**Step 1: Assess Current Implementation** - -```php -// Current code (before migration) -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production' -]); - -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); - -if ($response->statusCode === 401) { - // Manual re-authentication - $fs->oauthPassword($username, $password); - - // Manual retry - $response = $fs->get('/platform/tree/persons/PPPP-PPP'); -} -``` - -**Step 2: Add Callback for Automatic Handling** - -```php -// After migration (automatic handling) -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - if ($reason === 'expired') { - $fs->oauthPassword($username, $password); - // Automatic retry happens (no manual retry needed) - } - } -]); - -// Simplified code - no manual 401 handling needed -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); -// Request succeeds even if token was expired (automatic re-auth + retry) -``` - -**Step 3 (Optional): Add Proactive Checking** - -```php -// Add proactive expiration checking for better UX -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'expirationWarningThreshold' => 600, // Check 10 minutes before expiration - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - $fs->oauthPassword($username, $password); - } -]); - -// Check before making requests -if ($fs->isTokenExpired()) { - echo "Token is expiring soon, re-authenticating proactively...\n"; - $fs->oauthPassword($username, $password); -} - -// Make requests -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); -``` - -### Migration Checklist - -- [ ] **Review current 401 handling** - Identify where you manually handle authentication failures -- [ ] **Add callback** - Implement `onAuthenticationFailure` callback with re-authentication logic -- [ ] **Remove manual 401 checks** - Let callback handle 401s automatically (optional - both approaches work) -- [ ] **Test re-authentication** - Verify callback re-authenticates correctly -- [ ] **Add proactive checking** - Use `isTokenExpired()` before long-running operations (optional) -- [ ] **Configure threshold** - Adjust `expirationWarningThreshold` for your use case (optional) -- [ ] **Enable encryption** - Add `sessionEncryption` for production (recommended, see SECURITY.md) - -### Zero-Downtime Migration - -The SDK supports gradual migration without downtime: - -```php -// Phase 1: Keep existing 401 handling, add callback for logging -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'replayFailedRequestsAfterAuth' => false, // Disable replay initially - 'onAuthenticationFailure' => function($response, $reason) { - // Just log for now, don't re-authenticate yet - error_log("Would have re-authenticated: {$reason}"); - } -]); - -// Existing manual handling still works -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); -if ($response->statusCode === 401) { - $fs->oauthPassword($username, $password); - $response = $fs->get('/platform/tree/persons/PPPP-PPP'); -} - -// Phase 2: Enable automatic re-authentication in callback -// (Update callback to actually re-authenticate) - -// Phase 3: Enable automatic replay -// (Set replayFailedRequestsAfterAuth to true) - -// Phase 4: Remove manual 401 handling -// (Callback handles everything automatically) -``` - -## Addressing Issue #2 (2016) - -This token expiration handling feature directly addresses [Issue #2](https://github.com/FamilySearch/fs-php-lite/issues/2) opened in 2016. - -### The Original Problem - -Before this enhancement (issue opened 2016-09-09): - -```php -// Developers had to manually detect 401s and re-authenticate -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); - -if ($response->statusCode === 401) { - // Manual detection required - // Manual re-authentication required - // Manual retry required - $fs->oauthPassword($username, $password); - $response = $fs->get('/platform/tree/persons/PPPP-PPP'); -} - -// Problems: -// - No visibility into token expiration -// - No proactive handling possible -// - Repetitive boilerplate code -// - No automatic retry mechanism -// - No distinction between expired vs. invalid tokens -``` - -### The Solution (2024) - -After this enhancement (versions 1.4.0 - 1.6.0): - -```php -// Three flexible approaches with automatic handling -$fs = new FamilySearch([ - 'appKey' => $_ENV['FS_APP_KEY'], - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - $fs->oauthPassword($username, $password); - // Automatic retry - no manual code needed - } -]); - -// Simplified code - no manual 401 handling needed -$response = $fs->get('/platform/tree/persons/PPPP-PPP'); -// Works transparently even if token expired - -// Benefits: -// ✅ Proactive expiration checking (isTokenExpired) -// ✅ Expiration time visibility (getTokenExpirationTime) -// ✅ Automatic callback-based re-authentication -// ✅ Automatic request replay -// ✅ Clear distinction: 'expired' vs 'invalid' -// ✅ Activity tracking (60-minute window) -// ✅ Absolute expiration tracking (24-hour window) -``` - -### Why It Took 8 Years - -The original issue required understanding FamilySearch's unique token behavior: - -1. **No `expires_in` field** - Required client-side tracking implementation -2. **Dual expiration conditions** - Both 24-hour absolute AND 60-minute inactivity -3. **Activity resets timer** - Each API call extends the 60-minute window -4. **No refresh tokens** - Must re-authenticate, not refresh -5. **Backward compatibility** - Existing implementations must continue working - -This enhancement was carefully designed to address all these requirements while maintaining full backward compatibility. - -## Examples Summary - -### Quick Reference +## Quick Reference ```php // Approach 1: Proactive Checking @@ -1099,102 +191,19 @@ if ($fs->isTokenExpired()) { // Approach 2: Automatic Callback $fs = new FamilySearch([ 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - $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']); -``` - -### Complete Working Example - -```php - $_ENV['FS_APP_KEY'], - 'environment' => 'production', - 'sessions' => true, - 'sessionEncryption' => true, - 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'], - 'expirationWarningThreshold' => 300, // Warn 5 minutes before expiration - 'replayFailedRequestsAfterAuth' => true, // Enable automatic replay - 'onAuthenticationFailure' => function($response, $reason) use (&$fs, $username, $password) { - error_log("Authentication failed: {$reason}"); - if ($reason === 'expired') { - // Re-authenticate automatically - $authResponse = $fs->oauthPassword($username, $password); - - if ($authResponse->statusCode === 200) { - error_log('Successfully re-authenticated'); - } else { - error_log('Re-authentication failed'); - throw new Exception('Unable to re-authenticate'); - } - } else { - // Token is invalid (possibly revoked) - throw new Exception('Authentication token is invalid'); + $fs->oauthPassword($username, $password); } } ]); -// Initial authentication -$response = $fs->oauthPassword($username, $password); -if ($response->statusCode !== 200) { - die("Initial authentication failed\n"); -} - -// Display token information +// Approach 3: Enhanced Token Info $tokenInfo = $fs->getAccessToken(true); -echo "Authenticated successfully\n"; -echo "Token expires: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']) . "\n"; - -// Make API requests - re-authentication happens automatically if needed -$response = $fs->get('/platform/users/current'); -if ($response->statusCode === 200) { - echo "Request successful\n"; - - if ($response->replayed ?? false) { - echo "Note: Request was automatically retried after token expiration\n"; - } -} - -// Check expiration status -if ($fs->isTokenExpired()) { - echo "Warning: Token is expired or expiring soon\n"; -} else { - $timeRemaining = $fs->getTokenExpirationTime() - time(); - $minutesRemaining = floor($timeRemaining / 60); - echo "Token valid for {$minutesRemaining} more minutes\n"; -} +echo "Expires: " . date('Y-m-d H:i:s', $tokenInfo['expires_at']); ``` ## Additional Resources -- **Main README**: [README.md](../README.md) - Getting started and basic usage -- **Security Guide**: [SECURITY.md](../SECURITY.md) - Session encryption and security best practices -- **Testing Guide**: [TESTING.md](../TESTING.md) - Writing tests for your application -- **FamilySearch API Docs**: https://developers.familysearch.org/ -- **Issue #2 (2016)**: https://github.com/FamilySearch/fs-php-lite/issues/2 - -## Support - -For questions, issues, or feature requests, please: -- Open an issue on GitHub: https://github.com/FamilySearch/fs-php-lite/issues -- Check the FamilySearch Developer Forum: https://developers.familysearch.org/ - ---- - -**Version Requirements**: Token expiration tracking requires fs-php-lite v1.4.0 or higher. - -- v1.4.0: Token expiration tracking (`isTokenExpired()`, `getTokenExpirationTime()`) -- v1.5.0: Authentication failure callback (`onAuthenticationFailure`) -- v1.6.0: Automatic request replay (`replayFailedRequestsAfterAuth`) +- [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/) From 7d8aeb935f4fe1c7ba9dd5b70faddc9eb36c02dd Mon Sep 17 00:00:00 2001 From: neil_pack Date: Fri, 21 Aug 2026 11:12:21 -0600 Subject: [PATCH 3/4] Cleaned up documentation in CHANGELOG.md --- CHANGELOG.md | 127 +++++++++++++++++++++++++++++++++++---------------- README.md | 27 +++++++---- 2 files changed, 105 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5691fe6..4624714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,74 @@ 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 @@ -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 @@ -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 fdb85c1..fa1fc59 100644 --- a/README.md +++ b/README.md @@ -421,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 @@ -446,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 ``` @@ -530,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. From 2427da19909618d26071837b3cab58a189415a01 Mon Sep 17 00:00:00 2001 From: neil_pack Date: Fri, 21 Aug 2026 11:21:59 -0600 Subject: [PATCH 4/4] Moved the documentation so that they are not on the root directory --- CHANGELOG.md | 4 ++-- README.md | 4 ++-- SECURITY.md => docs/SECURITY.md | 0 TESTING.md => docs/TESTING.md | 0 examples/.env.example | 2 +- examples/_includes.php | 2 +- src/FamilySearch.php | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) rename SECURITY.md => docs/SECURITY.md (100%) rename TESTING.md => docs/TESTING.md (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4624714..69aa335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,7 +75,7 @@ All notable changes to this project will be documented in this file. - 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) @@ -106,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 diff --git a/README.md b/README.md index fa1fc59..26b8072 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ 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 @@ -509,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 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/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 45da15a..19377a9 100644 --- a/src/FamilySearch.php +++ b/src/FamilySearch.php @@ -103,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; @@ -148,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;