From 9cf6afa609f14ff2b8ccf6f53035779597701a99 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 12:21:57 +0200 Subject: [PATCH 01/40] Centralized `startCommunication` and `endCommunication` into CommunicationProvider --- src/FMDataAPI.php | 12 ++---------- src/Supporting/CommunicationProvider.php | 22 ++++++++++++++++++++++ src/Supporting/FileMakerLayout.php | 7 ++----- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 24047eb..7d98820 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -273,14 +273,7 @@ public function setThrowException(bool $value): void */ public function startCommunication(): void { - try { - if ($this->provider->login()) { - $this->provider->keepAuth = true; - } - } catch (Exception $e) { - $this->provider->keepAuth = false; - throw $e; - } + $this->provider->startCommunication(); } /** @@ -289,8 +282,7 @@ public function startCommunication(): void */ public function endCommunication(): void { - $this->provider->keepAuth = false; - $this->provider->logout(); + $this->provider->endCommunication(); } /** diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 4fcef8a..3467bbc 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -263,6 +263,28 @@ public function __construct(string $solution, $this->errorCode = -1; } + /** + * @throws Exception In case of any error, an exception arises. + */ + public function startCommunication(): void + { + try { + $this->keepAuth = $this->login(); + } catch (Exception $e) { + $this->keepAuth = false; + throw $e; + } + } + + /** + * @throws Exception In case of any error, an exception arises. + */ + public function endCommunication(): void + { + $this->keepAuth = false; + $this->logout(); + } + /** * @param array $params Array to build the API path. Ex: `["layouts" => null]` or `["sessions" => $this->accessToken]`. * @param string|array|null $request The query parameters as `"key" => "value"`. diff --git a/src/Supporting/FileMakerLayout.php b/src/Supporting/FileMakerLayout.php index 5bb0c32..6e158be 100644 --- a/src/Supporting/FileMakerLayout.php +++ b/src/Supporting/FileMakerLayout.php @@ -48,9 +48,7 @@ public function __construct(CommunicationProvider|null $restAPI, */ public function startCommunication(): void { - if ($this->restAPI->login()) { - $this->restAPI->keepAuth = true; - } + $this->restAPI->startCommunication(); } /** @@ -59,8 +57,7 @@ public function startCommunication(): void */ public function endCommunication(): void { - $this->restAPI->keepAuth = false; - $this->restAPI->logout(); + $this->restAPI->endCommunication(); } /** From 699d46833d7df1baa5bdd615f70219a2a8483e0b Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 12:51:39 +0200 Subject: [PATCH 02/40] Added retry mechanism on `callRestAPI` on authentication errors. --- src/FMDataAPI.php | 12 +++ src/Supporting/CommunicationProvider.php | 114 +++++++++++++++++++++-- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 7d98820..be8a44b 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -417,4 +417,16 @@ public function setExcludeTimeStampInException(bool $value = true): void { $this->provider->excludeTimeStampInException = $value; } + + /** + * This property determines whether a failed Data API call fails due to a session invalidation, a new session is + * established, and the call is retried once. This retry would be done in a new session, meaning that any FMS + * Data API operations that need to run in the same sessions would have unpredictable functionality. An example + * of an affected FMS Data API operation is setting and reading global fields. + * @param bool $value + */ + public function setRetryOnAccessTokenInvalidation(bool $value = true): void + { + $this->provider->retryOnAccessTokenInvalidation = $value; + } } diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 3467bbc..dcb8c7e 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -221,6 +221,17 @@ class CommunicationProvider */ public bool $excludeTimeStampInException = false; + /** + * @var bool + * @ignore + */ + public bool $retryOnAccessTokenInvalidation = false; + /** + * @var bool + * @ignore + */ + public bool $inCommunicationScope = false; + /** * CommunicationProvider constructor. * @param string $solution @@ -274,6 +285,8 @@ public function startCommunication(): void $this->keepAuth = false; throw $e; } + + $this->inCommunicationScope = true; } /** @@ -281,6 +294,7 @@ public function startCommunication(): void */ public function endCommunication(): void { + $this->inCommunicationScope = false; $this->keepAuth = false; $this->logout(); } @@ -529,7 +543,7 @@ public function login(): bool $request = []; $request["fmDataSource"] = (!is_null($this->fmDataSource)) ? $this->fmDataSource : []; try { - $this->callRestAPI($params, false, "POST", $request, $headers); // Throw Exception + $this->callRestAPIWithoutRetry($params, false, "POST", $request, $headers); // Throw Exception $this->storeToProperties(); if ($this->httpStatus == 200 && $this->errorCode == 0) { $this->accessToken = $this->responseBody->response->token; @@ -554,7 +568,7 @@ public function logout(): void return; } $params = ["sessions" => $this->accessToken]; - $this->callRestAPI($params, true, "DELETE"); // Throw Exception + $this->callRestAPIWithoutRetry($params, true, "DELETE"); // Throw Exception $this->accessToken = null; } @@ -624,13 +638,13 @@ private function getOAuthIdentifier($provider): array|null * @throws Exception In case of any error, an exception arises. * @ignore */ - public function callRestAPI(array $params, - bool $isAddToken, - string $method = 'GET', - string|array|null $request = null, - array|null $addHeader = null, - bool $isSystem = false, - string|null|false $directPath = null): void + public function callRestAPIWithoutRetry(array $params, + bool $isAddToken, + string $method = 'GET', + string|array|null $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void { $methodLower = strtolower($method); $url = $this->getURL($params, $request, $methodLower, $isSystem, $directPath); @@ -708,6 +722,59 @@ public function callRestAPI(array $params, } } + /** + * @param array $params + * @param bool $isAddToken + * @param string $method + * @param string|array|null $request + * @param array|null $addHeader + * @param bool $isSystem for Metadata + * @param string|null|false $directPath + * @return void + * @throws Exception In case of any error, an exception arises. + * @ignore + */ + public function callRestAPI(array $params, + bool $isAddToken, + string $method = 'GET', + string|array|null $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void + { + $caughtException = null; + + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + if ($this->shouldRetryOnError()) { + $caughtException = $e; + } else { + throw $e; + } + } + + if ($this->shouldRetryOnError()) { + $wasInCommunicationScope = $this->inCommunicationScope; + + $this->inCommunicationScope = false; + $this->accessToken = null; + $this->keepAuth = false; + + try { + if ($wasInCommunicationScope) { + $this->startCommunication(); + } else { + $this->login(); + } + + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $caughtException); + } + } + } + /** * Return the base64 encoded data in the container field. * Thanks to 'base64bits' as https://github.com/msyk/FMDataAPI/issues/18. @@ -856,6 +923,35 @@ public function debugOutput(bool $isReturnValue = false): string return ""; } + /** + * @return bool + * @ignore + */ + private function shouldRetryOnError(): bool + { + $errorCode = $this->extractErrorCode(); + // Error code 952 - Invalid FileMaker Data API token + // Occurs when the access token has expired or been invalidated. + // Error code 112 - "Window is missing" (likely unintentional by the FileMaker Data API) + // Reproducible when a Data API session is closed externally mid-request, + // producing a spurious "window missing" error rather than a proper auth failure. + return $this->retryOnAccessTokenInvalidation && ($errorCode == 952 || $errorCode == 112); + } + + /** + * @return int + * @ignore + */ + private function extractErrorCode(): int + { + $errorCode = -1; + if (is_object($this->responseBody) && property_exists($this->responseBody, 'messages')) { + $result = $this->responseBody->messages[0]; + $errorCode = property_exists($result, 'code') ? $result->code : -1; + } + return $errorCode; + } + /** * @param array $value * @return string From 311a768af75f1382b609a4b6837329834a72213d Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 13:24:44 +0200 Subject: [PATCH 03/40] Improved logic for setting inCommunicationScope in startCommunication method --- src/Supporting/CommunicationProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index dcb8c7e..a1723a7 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -284,9 +284,9 @@ public function startCommunication(): void } catch (Exception $e) { $this->keepAuth = false; throw $e; + } finally { + $this->inCommunicationScope = $this->keepAuth; } - - $this->inCommunicationScope = true; } /** From 5b18b9ffbcff60e77e2b47f63f2147909ddd6846 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 13:46:22 +0200 Subject: [PATCH 04/40] Now uses the result of login on retrying --- src/Supporting/CommunicationProvider.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index a1723a7..7c217ff 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -764,11 +764,14 @@ public function callRestAPI(array $params, try { if ($wasInCommunicationScope) { $this->startCommunication(); + $loggedIn = $this->keepAuth; } else { - $this->login(); + $loggedIn = $this->login(); } - $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + if ($loggedIn) { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } } catch (Exception $e) { throw new Exception($e->getMessage(), $e->getCode(), $caughtException); } From 43e171226e15c66aa0859a4b86932853344095df Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 15:01:12 +0200 Subject: [PATCH 05/40] Clarified documentation on setRetryOnAccessTokenInvalidation --- src/FMDataAPI.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index be8a44b..0acd9ac 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -419,10 +419,13 @@ public function setExcludeTimeStampInException(bool $value = true): void } /** - * This property determines whether a failed Data API call fails due to a session invalidation, a new session is - * established, and the call is retried once. This retry would be done in a new session, meaning that any FMS - * Data API operations that need to run in the same sessions would have unpredictable functionality. An example - * of an affected FMS Data API operation is setting and reading global fields. + * Controls whether failed Data API calls are automatically retried after session invalidation. + * + * When enabled and a call fails with error 952 (invalid token) or 112 (window missing), the + * current session is discarded, a new session is established, and the call is retried once. + * + * Warning: The retry runs in a fresh session. Any session-scoped state from the original session + * is lost — for example, global fields set before the retry will not carry over. * @param bool $value */ public function setRetryOnAccessTokenInvalidation(bool $value = true): void From c82c201678fe11cf82950474c8c822587346d18d Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 15:03:41 +0200 Subject: [PATCH 06/40] Replaced inCommunicationScope with keepAuth --- src/Supporting/CommunicationProvider.php | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 7c217ff..de6942a 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -226,11 +226,6 @@ class CommunicationProvider * @ignore */ public bool $retryOnAccessTokenInvalidation = false; - /** - * @var bool - * @ignore - */ - public bool $inCommunicationScope = false; /** * CommunicationProvider constructor. @@ -284,8 +279,6 @@ public function startCommunication(): void } catch (Exception $e) { $this->keepAuth = false; throw $e; - } finally { - $this->inCommunicationScope = $this->keepAuth; } } @@ -294,7 +287,6 @@ public function startCommunication(): void */ public function endCommunication(): void { - $this->inCommunicationScope = false; $this->keepAuth = false; $this->logout(); } @@ -755,14 +747,13 @@ public function callRestAPI(array $params, } if ($this->shouldRetryOnError()) { - $wasInCommunicationScope = $this->inCommunicationScope; + $wasKeepAuth = $this->keepAuth; - $this->inCommunicationScope = false; $this->accessToken = null; $this->keepAuth = false; try { - if ($wasInCommunicationScope) { + if ($wasKeepAuth) { $this->startCommunication(); $loggedIn = $this->keepAuth; } else { From a415dee3058e078f6de46009dc24300e14a3a5f9 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Thu, 30 Apr 2026 15:28:50 +0200 Subject: [PATCH 07/40] Added minor documentation to the callRestAPI functions to clarify their differences --- src/Supporting/CommunicationProvider.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index de6942a..45922f1 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -619,6 +619,7 @@ private function getOAuthIdentifier($provider): array|null } /** + * Sends a REST API request to the FileMaker Data API without any retry logic. * @param array $params * @param bool $isAddToken * @param string $method @@ -628,6 +629,7 @@ private function getOAuthIdentifier($provider): array|null * @param string|null|false $directPath * @return void * @throws Exception In case of any error, an exception arises. + * @see callRestAPI() For the recommended entry point with automatic retry on session invalidation. * @ignore */ public function callRestAPIWithoutRetry(array $params, @@ -715,6 +717,8 @@ public function callRestAPIWithoutRetry(array $params, } /** + * Sends a REST API request to the FileMaker Data API, retrying once on session invalidation if + * the retryOnAccessTokenInvalidation property is enabled. * @param array $params * @param bool $isAddToken * @param string $method @@ -723,7 +727,9 @@ public function callRestAPIWithoutRetry(array $params, * @param bool $isSystem for Metadata * @param string|null|false $directPath * @return void - * @throws Exception In case of any error, an exception arises. + * @throws Exception In case of any error, an exception arises. If a retry was attempted, + * the original exception is available via getPrevious(). + * @see callRestAPIWithoutRetry() To bypass retry logic entirely. * @ignore */ public function callRestAPI(array $params, From ab895f7e4ab2bd890c940b1df8c938d04dcb4e05 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 5 May 2026 10:31:03 +0200 Subject: [PATCH 08/40] Added initial persistent session setup and session cache interface. --- src/FMDataAPI.php | 23 +++- src/PersistentSession/ApcuSessionCache.php | 59 ++++++++++ .../PersistentSessionStore.php | 101 ++++++++++++++++++ .../SessionCacheInterface.php | 40 +++++++ src/Supporting/CommunicationProvider.php | 12 ++- test/TestProvider.php | 7 +- 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/PersistentSession/ApcuSessionCache.php create mode 100644 src/PersistentSession/PersistentSessionStore.php create mode 100644 src/PersistentSession/SessionCacheInterface.php diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 0acd9ac..a5e8500 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -2,6 +2,8 @@ namespace INTERMediator\FileMakerServer\RESTAPI; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\PersistentSessionStore; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\SessionCacheInterface; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerLayout; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerRelation; use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider; @@ -58,6 +60,11 @@ class FMDataAPI * Ex. [{"database"=>"", "username"=>"", "password"=>""}]. * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. + * @param SessionCacheInterface|null $sessionCache Cache backend for persistent sessions. + * This stores the authentication token used for persistent session reuse. + * If omitted, persistent session caching is disabled and the library keeps the normal login/logout behavior. + * If specified, the host is used as the token scope and startCommunication() / endCommunication() + * will reuse session tokens between requests. */ public function __construct(string $solution, string $user, @@ -66,15 +73,25 @@ public function __construct(string $solution, int|null $port = null, string|null $protocol = null, array|null $fmDataSource = null, - bool $isUnitTest = false) + bool $isUnitTest = false, + SessionCacheInterface|null $sessionCache = null) { if (is_null($password)) { $password = "password"; // For testing purpose. } + + $sessionStore = null; + if ($sessionCache !== null) { + $scope = ($host === 'localserver') + ? 'http://127.0.0.1:3000' + : sprintf('%s://%s:%d', $protocol ?? 'https', $host ?? '127.0.0.1', $port ?? 443); + $sessionStore = new PersistentSessionStore($sessionCache, $solution, $user, $scope); + } + if (!$isUnitTest) { - $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); } else { - $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); } } diff --git a/src/PersistentSession/ApcuSessionCache.php b/src/PersistentSession/ApcuSessionCache.php new file mode 100644 index 0000000..8cd5185 --- /dev/null +++ b/src/PersistentSession/ApcuSessionCache.php @@ -0,0 +1,59 @@ +cache = $cache; + $this->database = $database; + $this->user = $user; + $this->scope = $scope; + } + + /** + * Retrieve a cached token. + * @return string|null Returns the cached token, or null if the key doesn't exist. + */ + public function get(): ?string + { + return $this->cache->get($this->cacheKey()); + } + + /** + * Cache the current session token. + * @param string $token The session token. + * @return bool|null Returns the result from the cache backend. + */ + public function set(string $token): ?bool + { + return $this->cache->set($this->cacheKey(), $token, self::TOKEN_TTL); + } + + /** + * Clear the cached session token. + * @return bool|null Returns the result from the cache backend. + */ + public function clear(): ?bool + { + return $this->cache->delete($this->cacheKey()); + } + + /** + * Build the cache key from the database name, user name, and scope. + * @return string + */ + private function cacheKey(): string + { + return 'fm_token:' . hash('sha256', $this->scope . '|' . $this->database . '|' . $this->user); + } +} diff --git a/src/PersistentSession/SessionCacheInterface.php b/src/PersistentSession/SessionCacheInterface.php new file mode 100644 index 0000000..652075b --- /dev/null +++ b/src/PersistentSession/SessionCacheInterface.php @@ -0,0 +1,40 @@ +solution = rawurlencode($solution); $this->user = $user; @@ -266,6 +275,7 @@ public function __construct(string $solution, } } $this->fmDataSource = $fmDataSource; + $this->sessionStore = $sessionStore; $this->errorCode = -1; } diff --git a/test/TestProvider.php b/test/TestProvider.php index 4f02327..c1b4586 100644 --- a/test/TestProvider.php +++ b/test/TestProvider.php @@ -9,6 +9,7 @@ namespace INTERMediator\FileMakerServer\RESTAPI\Supporting; use Exception; +use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\PersistentSessionStore; class TestProvider extends CommunicationProvider { @@ -22,6 +23,7 @@ class TestProvider extends CommunicationProvider * @param string|null $port * @param string|null $protocol * @param array|null $fmDataSource + * @param PersistentSessionStore|null $sessionStore * @ignore */ public function __construct(string $solution, @@ -30,9 +32,10 @@ public function __construct(string $solution, string|null $host = null, string|null $port = null, string|null $protocol = null, - array|null $fmDataSource = null) + array|null $fmDataSource = null, + PersistentSessionStore|null $sessionStore = null) { - parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource); + parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); $this->buildResponses(); } From fbf66caad8046b878f9aa60e18ef0b347f2b097d Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 5 May 2026 10:32:57 +0200 Subject: [PATCH 09/40] Adjusted logic of startCommunication and endCommunication to handle persistent sessions and token renewal. --- src/FMDataAPI.php | 32 +++++++++++++--- src/Supporting/CommunicationProvider.php | 47 ++++++++++++++++++++++-- src/Supporting/FileMakerLayout.php | 25 +++++++++++-- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index a5e8500..2e51da6 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -281,11 +281,18 @@ public function setThrowException(bool $value): void } /** - * Start a transaction which is a serial calling of multiple database operations before the single authentication. - * Usually most methods login and logout before/after the database operation, and so a little bit of time is going to - * take. - * The startCommunication() login and endCommunication() logout, and methods between them don't log in/out, and - * it can expect faster operations. + * Start a communication scope with a shared authenticated session. + * + * Usually most methods login and logout before and after each database operation. + * By calling startCommunication() and endCommunication(), methods between them don't + * log in and out every time, and it can expect faster operations. + * + * When persistent sessions are not enabled, one authenticated session is kept during + * the current communication scope. + * + * When persistent sessions are enabled, the cached session token is reused if available. + * If there is no cached token, a new session is created and stored. + * * @throws Exception */ public function startCommunication(): void @@ -294,7 +301,16 @@ public function startCommunication(): void } /** - * Finish a transaction which is a serial calling of any database operations, and logout. + * Finish a communication scope. + * + * When persistent sessions are not enabled, the authenticated session for the current + * communication scope is ended and the server session is logged out. + * + * When persistent sessions are enabled, the cached token is renewed if it still matches + * the token held by this instance. If another worker has replaced the cached token in + * the meantime, only this instance's (now-stale) token is logged out, leaving the + * newer cached token intact. + * * @throws Exception */ public function endCommunication(): void @@ -441,6 +457,10 @@ public function setExcludeTimeStampInException(bool $value = true): void * When enabled and a call fails with error 952 (invalid token) or 112 (window missing), the * current session is discarded, a new session is established, and the call is retried once. * + * When a session cache is provided to the constructor, retry on token invalidation is always + * active regardless of this setting. This flag only has an effect when no session cache is + * configured. + * * Warning: The retry runs in a fresh session. Any session-scoped state from the original session * is lost — for example, global fields set before the retry will not carry over. * @param bool $value diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 86500e1..b03a806 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -293,11 +293,25 @@ public function startCommunication(): void } /** + * Close the scope. If persistent mode is on and our token is the one currently + * advertised in the cache, renew its TTL and leave it alive. Otherwise fall through + * to logout(), which will DELETE our (orphan) token at the server. * @throws Exception In case of any error, an exception arises. */ public function endCommunication(): void { $this->keepAuth = false; + + if ($this->sessionStore !== null && $this->accessToken !== null) { + if ($this->sessionStore->get() === $this->accessToken) { + $this->sessionStore->set($this->accessToken); // renew TTL + $this->accessToken = null; + return; + } + // Mismatch: another worker replaced the cached token while we were active. + // Our token is an orphan — fall through and DELETE it, but don't touch the cache. + } + $this->logout(); } @@ -528,6 +542,14 @@ public function login(): bool return true; } + if ($this->sessionStore !== null) { + $cached = $this->sessionStore->get(); + if ($cached !== null) { + $this->accessToken = $cached; + return true; + } + } + if ($this->useOAuth) { $headers = [ "Content-Type" => "application/json", @@ -549,6 +571,9 @@ public function login(): bool $this->storeToProperties(); if ($this->httpStatus == 200 && $this->errorCode == 0) { $this->accessToken = $this->responseBody->response->token; + if ($this->sessionStore !== null) { + $this->sessionStore->set($this->accessToken); + } return true; } } catch (Exception $e) { @@ -559,7 +584,10 @@ public function login(): bool } /** - * + * Tear down the current server-side session, unless either: + * - we're inside a multi-call scope (keepAuth), or + * - this token is the one currently shared via the persistent cache + * (in which case the cache owns its lifecycle). * @return void * @throws Exception In case of any error, an exception arises. * @ignore @@ -569,9 +597,20 @@ public function logout(): void if ($this->keepAuth) { return; } - $params = ["sessions" => $this->accessToken]; - $this->callRestAPIWithoutRetry($params, true, "DELETE"); // Throw Exception - $this->accessToken = null; + if ($this->accessToken === null) { + return; + } + if ($this->sessionStore !== null && $this->sessionStore->get() === $this->accessToken) { + $this->accessToken = null; + return; + } + + try { + $params = ["sessions" => $this->accessToken]; + $this->callRestAPIWithoutRetry($params, true, "DELETE"); // Throw Exception + } finally { + $this->accessToken = null; + } } /** diff --git a/src/Supporting/FileMakerLayout.php b/src/Supporting/FileMakerLayout.php index 6e158be..efff41d 100644 --- a/src/Supporting/FileMakerLayout.php +++ b/src/Supporting/FileMakerLayout.php @@ -42,8 +42,18 @@ public function __construct(CommunicationProvider|null $restAPI, } /** - * Start a transaction which is a serial calling of any database operations - * and log in with the target layout. + * Start a communication scope with a shared authenticated session. + * + * Usually most methods login and logout before and after each database operation. + * By calling startCommunication() and endCommunication(), methods between them don't + * log in and out every time, and it can expect faster operations. + * + * When persistent sessions are not enabled, one authenticated session is kept during + * the current communication scope. + * + * When persistent sessions are enabled, the cached session token is reused if available. + * If there is no cached token, a new session is created and stored. + * * @throws Exception */ public function startCommunication(): void @@ -52,7 +62,16 @@ public function startCommunication(): void } /** - * Finish a transaction which is a serial calling of any database operations, and logout. + * Finish a communication scope. + * + * When persistent sessions are not enabled, the authenticated session for the current + * communication scope is ended and the server session is logged out. + * + * When persistent sessions are enabled, the cached token is renewed if it still matches + * the token held by this instance. If another worker has replaced the cached token in + * the meantime, only this instance's (now-stale) token is logged out, leaving the + * newer cached token intact. + * * @throws Exception */ public function endCommunication(): void From 8e3b4429c25e0b167f35a2c03384c89961fd040c Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 5 May 2026 10:33:55 +0200 Subject: [PATCH 10/40] Made callRestAPIWithoutRetry private and made callRestAPI the only entry point for calling REST API --- src/Supporting/CommunicationProvider.php | 128 ++++++++++++----------- 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index b03a806..1d2546a 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -667,6 +667,65 @@ private function getOAuthIdentifier($provider): array|null } } + /** + * Sends a REST API request to the FileMaker Data API, retrying once on session invalidation if + * the retryOnAccessTokenInvalidation property is enabled. + * @param array $params + * @param bool $isAddToken + * @param string $method + * @param string|array|null $request + * @param array|null $addHeader + * @param bool $isSystem for Metadata + * @param string|null|false $directPath + * @return void + * @throws Exception In case of any error, an exception arises. If a retry was attempted, + * the original exception is available via getPrevious(). + * @see callRestAPIWithoutRetry() To bypass retry logic entirely. + * @ignore + */ + public function callRestAPI(array $params, + bool $isAddToken, + string $method = 'GET', + string|array|null $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void + { + $firstAttempt = null; + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + $firstAttempt = $e; + } + + if (!$this->shouldRetryOnTokenError()) { + if ($firstAttempt !== null) { + throw $firstAttempt; + } + return; + } + + // Token rejected by the server. Clear the cache before re-login so racing workers + // don't re-adopt the dead token; preserve the in-process scope across the re-login. + if ($this->sessionStore !== null) { + $this->sessionStore->clear(); + } + $resumeScope = $this->keepAuth; + $this->accessToken = null; + $this->keepAuth = false; + try { + $reauthed = $this->login(); + if ($reauthed && $resumeScope) { + $this->keepAuth = true; + } + if ($reauthed) { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } + } catch (Exception $retry) { + throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + } + } + /** * Sends a REST API request to the FileMaker Data API without any retry logic. * @param array $params @@ -681,7 +740,7 @@ private function getOAuthIdentifier($provider): array|null * @see callRestAPI() For the recommended entry point with automatic retry on session invalidation. * @ignore */ - public function callRestAPIWithoutRetry(array $params, + private function callRestAPIWithoutRetry(array $params, bool $isAddToken, string $method = 'GET', string|array|null $request = null, @@ -765,65 +824,6 @@ public function callRestAPIWithoutRetry(array $params, } } - /** - * Sends a REST API request to the FileMaker Data API, retrying once on session invalidation if - * the retryOnAccessTokenInvalidation property is enabled. - * @param array $params - * @param bool $isAddToken - * @param string $method - * @param string|array|null $request - * @param array|null $addHeader - * @param bool $isSystem for Metadata - * @param string|null|false $directPath - * @return void - * @throws Exception In case of any error, an exception arises. If a retry was attempted, - * the original exception is available via getPrevious(). - * @see callRestAPIWithoutRetry() To bypass retry logic entirely. - * @ignore - */ - public function callRestAPI(array $params, - bool $isAddToken, - string $method = 'GET', - string|array|null $request = null, - array|null $addHeader = null, - bool $isSystem = false, - string|null|false $directPath = null): void - { - $caughtException = null; - - try { - $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); - } catch (Exception $e) { - if ($this->shouldRetryOnError()) { - $caughtException = $e; - } else { - throw $e; - } - } - - if ($this->shouldRetryOnError()) { - $wasKeepAuth = $this->keepAuth; - - $this->accessToken = null; - $this->keepAuth = false; - - try { - if ($wasKeepAuth) { - $this->startCommunication(); - $loggedIn = $this->keepAuth; - } else { - $loggedIn = $this->login(); - } - - if ($loggedIn) { - $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); - } - } catch (Exception $e) { - throw new Exception($e->getMessage(), $e->getCode(), $caughtException); - } - } - } - /** * Return the base64 encoded data in the container field. * Thanks to 'base64bits' as https://github.com/msyk/FMDataAPI/issues/18. @@ -976,15 +976,19 @@ public function debugOutput(bool $isReturnValue = false): string * @return bool * @ignore */ - private function shouldRetryOnError(): bool + private function shouldRetryOnTokenError(): bool { + if ($this->sessionStore === null && !$this->retryOnAccessTokenInvalidation) { + return false; + } + $errorCode = $this->extractErrorCode(); // Error code 952 - Invalid FileMaker Data API token // Occurs when the access token has expired or been invalidated. // Error code 112 - "Window is missing" (likely unintentional by the FileMaker Data API) // Reproducible when a Data API session is closed externally mid-request, // producing a spurious "window missing" error rather than a proper auth failure. - return $this->retryOnAccessTokenInvalidation && ($errorCode == 952 || $errorCode == 112); + return $errorCode === 952 || $errorCode === 112; } /** From 45c3445642cb5b0494bed2870c5739570dda8c1e Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 6 May 2026 09:29:13 +0200 Subject: [PATCH 11/40] Converted error code to int --- src/Supporting/CommunicationProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 1d2546a..0d6f1be 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -1000,7 +1000,7 @@ private function extractErrorCode(): int $errorCode = -1; if (is_object($this->responseBody) && property_exists($this->responseBody, 'messages')) { $result = $this->responseBody->messages[0]; - $errorCode = property_exists($result, 'code') ? $result->code : -1; + $errorCode = property_exists($result, 'code') ? intval($result->code) : -1; } return $errorCode; } From 7e748bc95128816719fe88107d5073eeac325373 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 6 May 2026 10:25:20 +0200 Subject: [PATCH 12/40] Made callRestAPIWithoutRetry protected and made TestProvider override it --- src/Supporting/CommunicationProvider.php | 14 +++++++------- test/TestProvider.php | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 0d6f1be..2200f14 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -740,13 +740,13 @@ public function callRestAPI(array $params, * @see callRestAPI() For the recommended entry point with automatic retry on session invalidation. * @ignore */ - private function callRestAPIWithoutRetry(array $params, - bool $isAddToken, - string $method = 'GET', - string|array|null $request = null, - array|null $addHeader = null, - bool $isSystem = false, - string|null|false $directPath = null): void + protected function callRestAPIWithoutRetry(array $params, + bool $isAddToken, + string $method = 'GET', + string|array|null $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void { $methodLower = strtolower($method); $url = $this->getURL($params, $request, $methodLower, $isSystem, $directPath); diff --git a/test/TestProvider.php b/test/TestProvider.php index c1b4586..1fbc16a 100644 --- a/test/TestProvider.php +++ b/test/TestProvider.php @@ -52,13 +52,13 @@ public function __construct(string $solution, * @throws Exception In case of any error, an exception arises. * @ignore */ - public function callRestAPI(array $params, - bool $isAddToken, - string $method = 'GET', - array|null|string $request = null, - array|null $addHeader = null, - bool $isSystem = false, - string|null|false $directPath = null): void + protected function callRestAPIWithoutRetry(array $params, + bool $isAddToken, + string $method = 'GET', + array|null|string $request = null, + array|null $addHeader = null, + bool $isSystem = false, + string|null|false $directPath = null): void { $methodLower = strtolower($method); $url = $this->getURL($params, $request, $methodLower); From cceb0f460befc164324ba5372c5fadc442579f04 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 6 May 2026 10:25:44 +0200 Subject: [PATCH 13/40] Added comment to explain edge case of orphaned tokens --- src/Supporting/CommunicationProvider.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 2200f14..bd75781 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -304,6 +304,9 @@ public function endCommunication(): void if ($this->sessionStore !== null && $this->accessToken !== null) { if ($this->sessionStore->get() === $this->accessToken) { + // if the cache write fails, the token will expire naturally within 15 minutes. + // under sustained cache failures with high concurrency, orphaned tokens could + // approach FileMaker's session cap (error 953). $this->sessionStore->set($this->accessToken); // renew TTL $this->accessToken = null; return; From 73c72d94a21b7ee429dba7f522b1fb10c66a5355 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 13:28:58 +0200 Subject: [PATCH 14/40] Removed `PersistentSessionStore` and replaced it with `AbstractSessionCache` --- src/FMDataAPI.php | 35 +++--- src/PersistentSession/ApcuSessionCache.php | 59 ---------- .../PersistentSessionStore.php | 101 ----------------- .../SessionCacheInterface.php | 40 ------- src/SessionCache/AbstractSessionCache.php | 107 ++++++++++++++++++ src/SessionCache/ApcuSessionCache.php | 77 +++++++++++++ src/SessionCache/SessionCacheInterface.php | 62 ++++++++++ src/Supporting/CommunicationProvider.php | 69 +++++++---- test/TestProvider.php | 22 ++-- 9 files changed, 315 insertions(+), 257 deletions(-) delete mode 100644 src/PersistentSession/ApcuSessionCache.php delete mode 100644 src/PersistentSession/PersistentSessionStore.php delete mode 100644 src/PersistentSession/SessionCacheInterface.php create mode 100644 src/SessionCache/AbstractSessionCache.php create mode 100644 src/SessionCache/ApcuSessionCache.php create mode 100644 src/SessionCache/SessionCacheInterface.php diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 2e51da6..fe004b4 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -2,8 +2,7 @@ namespace INTERMediator\FileMakerServer\RESTAPI; -use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\PersistentSessionStore; -use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\SessionCacheInterface; +use INTERMediator\FileMakerServer\RESTAPI\SessionCache\AbstractSessionCache; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerLayout; use INTERMediator\FileMakerServer\RESTAPI\Supporting\FileMakerRelation; use INTERMediator\FileMakerServer\RESTAPI\Supporting\CommunicationProvider; @@ -60,38 +59,30 @@ class FMDataAPI * Ex. [{"database"=>"", "username"=>"", "password"=>""}]. * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. - * @param SessionCacheInterface|null $sessionCache Cache backend for persistent sessions. + * @param AbstractSessionCache|null $sessionCache Cache backend for persistent sessions. * This stores the authentication token used for persistent session reuse. * If omitted, persistent session caching is disabled and the library keeps the normal login/logout behavior. * If specified, the host is used as the token scope and startCommunication() / endCommunication() * will reuse session tokens between requests. */ - public function __construct(string $solution, - string $user, - string|null $password, - string|null $host = null, - int|null $port = null, - string|null $protocol = null, - array|null $fmDataSource = null, - bool $isUnitTest = false, - SessionCacheInterface|null $sessionCache = null) + public function __construct(string $solution, + string $user, + string|null $password, + string|null $host = null, + int|null $port = null, + string|null $protocol = null, + array|null $fmDataSource = null, + bool $isUnitTest = false, + AbstractSessionCache|null $sessionCache = null) { if (is_null($password)) { $password = "password"; // For testing purpose. } - $sessionStore = null; - if ($sessionCache !== null) { - $scope = ($host === 'localserver') - ? 'http://127.0.0.1:3000' - : sprintf('%s://%s:%d', $protocol ?? 'https', $host ?? '127.0.0.1', $port ?? 443); - $sessionStore = new PersistentSessionStore($sessionCache, $solution, $user, $scope); - } - if (!$isUnitTest) { - $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); + $this->provider = new Supporting\CommunicationProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); } else { - $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); + $this->provider = new Supporting\TestProvider($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); } } diff --git a/src/PersistentSession/ApcuSessionCache.php b/src/PersistentSession/ApcuSessionCache.php deleted file mode 100644 index 8cd5185..0000000 --- a/src/PersistentSession/ApcuSessionCache.php +++ /dev/null @@ -1,59 +0,0 @@ -cache = $cache; - $this->database = $database; - $this->user = $user; - $this->scope = $scope; - } - - /** - * Retrieve a cached token. - * @return string|null Returns the cached token, or null if the key doesn't exist. - */ - public function get(): ?string - { - return $this->cache->get($this->cacheKey()); - } - - /** - * Cache the current session token. - * @param string $token The session token. - * @return bool|null Returns the result from the cache backend. - */ - public function set(string $token): ?bool - { - return $this->cache->set($this->cacheKey(), $token, self::TOKEN_TTL); - } - - /** - * Clear the cached session token. - * @return bool|null Returns the result from the cache backend. - */ - public function clear(): ?bool - { - return $this->cache->delete($this->cacheKey()); - } - - /** - * Build the cache key from the database name, user name, and scope. - * @return string - */ - private function cacheKey(): string - { - return 'fm_token:' . hash('sha256', $this->scope . '|' . $this->database . '|' . $this->user); - } -} diff --git a/src/PersistentSession/SessionCacheInterface.php b/src/PersistentSession/SessionCacheInterface.php deleted file mode 100644 index 652075b..0000000 --- a/src/PersistentSession/SessionCacheInterface.php +++ /dev/null @@ -1,40 +0,0 @@ -redis->get($this->cacheKey) ?? null; + * } + * + * public function set(string $value): bool + * { + * return $this->redis->setex($this->cacheKey, $this->ttl, $value); + * } + * + * public function delete(): bool + * { + * return $this->redis->del($this->cacheKey) > 0; + * } + * } + * + * @see SessionCacheInterface + */ +abstract class AbstractSessionCache implements SessionCacheInterface +{ + /** + * The cache key for the current session. + * + * Always set by the library via {@see self::setCacheKey()} before any cache + * operation is performed. Will not change during a single PHP request. + * Implementing classes should use this property directly in their + * {@see SessionCacheInterface::get()}, {@see SessionCacheInterface::set()}, + * and {@see SessionCacheInterface::delete()} implementations. + */ + protected string $cacheKey; + + /** + * The time-to-live in seconds for cached session tokens. + * + * Set by the library via {@see self::setTtl()} before any cache operation + * is performed, defaulting to the value provided at construction time. + * Will not change during a single PHP request. Implementing classes should + * use this property directly in their {@see SessionCacheInterface::set()} implementation. + * + */ + protected int $ttl; + + /** + * @param int $defaultTtl Default time-to-live in seconds for cached session tokens. + * Defaults to 840 seconds (14 minutes), reflecting the + * default FileMaker Data API session timeout. Adjust this + * value if your FileMaker Server is configured with a + * different session timeout. + */ + public function __construct(int $defaultTtl = 840) + { + $this->ttl = $defaultTtl; + } + + /** + * Sets the cache key for the current session. + * + * This method is called internally by the library and should not be called + * manually. The key will not change during a single PHP request. + * + * @param string $key The cache key to use for subsequent cache operations. + */ + final public function setCacheKey(string $key): void + { + $this->cacheKey = $key; + } + + /** + * Sets the time-to-live for cached session tokens. + * + * This method is called internally by the library and should not be called + * manually. The TTL will not change during a single PHP request. + * + * @param int $ttl Time-to-live in seconds for the cached session token. + */ + final public function setTtl(int $ttl): void + { + $this->ttl = $ttl; + } +} diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php new file mode 100644 index 0000000..5dc06f1 --- /dev/null +++ b/src/SessionCache/ApcuSessionCache.php @@ -0,0 +1,77 @@ +cacheKey, $success); + return $success && is_string($value) ? $value : null; + } + + /** + * Persists a FileMaker Data API session token in APCu. + * + * @param string $value The FileMaker Data API session token to store. + * This is a sensitive credential and must be treated as such. + * @return bool True on success, false on failure. + */ + public function set(string $value): bool + { + return apcu_store($this->cacheKey, $value, $this->ttl); + } + + /** + * Deletes the cached FileMaker Data API session token. + * + * Returns false both when the key does not exist and when deletion fails. + * + * @return bool True on success, false if the key did not exist or deletion failed. + */ + public function delete(): bool + { + return apcu_delete($this->cacheKey); + } +} diff --git a/src/SessionCache/SessionCacheInterface.php b/src/SessionCache/SessionCacheInterface.php new file mode 100644 index 0000000..acd453c --- /dev/null +++ b/src/SessionCache/SessionCacheInterface.php @@ -0,0 +1,62 @@ +solution = rawurlencode($solution); $this->user = $user; @@ -275,8 +280,9 @@ public function __construct(string $solution, } } $this->fmDataSource = $fmDataSource; - $this->sessionStore = $sessionStore; + $this->sessionCache = $sessionCache; $this->errorCode = -1; + $this->sessionCache->setCacheKey($this->cacheKey()); } /** @@ -302,12 +308,12 @@ public function endCommunication(): void { $this->keepAuth = false; - if ($this->sessionStore !== null && $this->accessToken !== null) { - if ($this->sessionStore->get() === $this->accessToken) { + if ($this->sessionCache !== null && $this->accessToken !== null) { + if ($this->sessionCache->get() === $this->accessToken) { // if the cache write fails, the token will expire naturally within 15 minutes. // under sustained cache failures with high concurrency, orphaned tokens could // approach FileMaker's session cap (error 953). - $this->sessionStore->set($this->accessToken); // renew TTL + $this->sessionCache->set($this->accessToken); // renew TTL $this->accessToken = null; return; } @@ -545,8 +551,8 @@ public function login(): bool return true; } - if ($this->sessionStore !== null) { - $cached = $this->sessionStore->get(); + if ($this->sessionCache !== null) { + $cached = $this->sessionCache->get(); if ($cached !== null) { $this->accessToken = $cached; return true; @@ -574,8 +580,8 @@ public function login(): bool $this->storeToProperties(); if ($this->httpStatus == 200 && $this->errorCode == 0) { $this->accessToken = $this->responseBody->response->token; - if ($this->sessionStore !== null) { - $this->sessionStore->set($this->accessToken); + if ($this->sessionCache !== null) { + $this->sessionCache->set($this->accessToken); } return true; } @@ -603,7 +609,7 @@ public function logout(): void if ($this->accessToken === null) { return; } - if ($this->sessionStore !== null && $this->sessionStore->get() === $this->accessToken) { + if ($this->sessionCache !== null && $this->sessionCache->get() === $this->accessToken) { $this->accessToken = null; return; } @@ -710,8 +716,8 @@ public function callRestAPI(array $params, // Token rejected by the server. Clear the cache before re-login so racing workers // don't re-adopt the dead token; preserve the in-process scope across the re-login. - if ($this->sessionStore !== null) { - $this->sessionStore->clear(); + if ($this->sessionCache !== null) { + $this->sessionCache->delete(); } $resumeScope = $this->keepAuth; $this->accessToken = null; @@ -981,7 +987,7 @@ public function debugOutput(bool $isReturnValue = false): string */ private function shouldRetryOnTokenError(): bool { - if ($this->sessionStore === null && !$this->retryOnAccessTokenInvalidation) { + if ($this->sessionCache === null && !$this->retryOnAccessTokenInvalidation) { return false; } @@ -1088,4 +1094,19 @@ private function _createCurlHandle(string $url): CurlHandle } return $ch; } + + private function cacheKey(): string + { + $data = [ + $this->url, + $this->solution, + (string)$this->port, + $this->host, + $this->protocol, + ]; + + $hash = hash('sha256', implode('', $data)); + + return "fm_token:$hash"; + } } diff --git a/test/TestProvider.php b/test/TestProvider.php index 1fbc16a..e5e0d06 100644 --- a/test/TestProvider.php +++ b/test/TestProvider.php @@ -9,7 +9,7 @@ namespace INTERMediator\FileMakerServer\RESTAPI\Supporting; use Exception; -use INTERMediator\FileMakerServer\RESTAPI\PersistentSession\PersistentSessionStore; +use INTERMediator\FileMakerServer\RESTAPI\SessionCache\AbstractSessionCache; class TestProvider extends CommunicationProvider { @@ -23,19 +23,19 @@ class TestProvider extends CommunicationProvider * @param string|null $port * @param string|null $protocol * @param array|null $fmDataSource - * @param PersistentSessionStore|null $sessionStore + * @param AbstractSessionCache|null $sessionCache * @ignore */ - public function __construct(string $solution, - string $user, - string|null $password, - string|null $host = null, - string|null $port = null, - string|null $protocol = null, - array|null $fmDataSource = null, - PersistentSessionStore|null $sessionStore = null) + public function __construct(string $solution, + string $user, + string|null $password, + string|null $host = null, + string|null $port = null, + string|null $protocol = null, + array|null $fmDataSource = null, + AbstractSessionCache|null $sessionCache = null) { - parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionStore); + parent::__construct($solution, $user, $password, $host, $port, $protocol, $fmDataSource, $sessionCache); $this->buildResponses(); } From 2c091d5b72d2adb2979d5e875c9e7415dd4334d7 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 13:36:26 +0200 Subject: [PATCH 15/40] Added methods to override internal session cache properties --- src/FMDataAPI.php | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index fe004b4..7f76535 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -460,4 +460,44 @@ public function setRetryOnAccessTokenInvalidation(bool $value = true): void { $this->provider->retryOnAccessTokenInvalidation = $value; } + + /** + * Overrides the cache key used to store the FileMaker Data API session token. + * + * WARNING: Setting an incorrect cache key can cause session tokens to be shared + * across users or applications, which is a serious security risk. Do not use + * this method unless you fully understand the implications. + * + * The default cache key is a hashed representation of the following values: + * - The host name + * - The port number + * - The protocol (http or https) + * - The solution name + * - The user + * + * This default is sufficient for the vast majority of use cases. Only override + * this if you have a specific reason to do so. + * @param string $keyName The custom cache key name. + */ + public function setSessionCacheKeyName(string $keyName): void + { + $this->provider->sessionCache->setCacheKey($keyName); + } + + /** + * Overrides the time-to-live (TTL) of the cached FileMaker Data API session token. + * + * WARNING: Setting a TTL that exceeds the FileMaker Data API session timeout (15 minutes) + * will cause the library to use expired tokens, resulting in authentication failures. + * Do not use this method unless you fully understand the implications. + * + * The default TTL is 840 seconds (14 minutes), intentionally set one minute below the + * FileMaker Data API session timeout of 15 minutes to ensure the cached token is + * invalidated before it expires on the FileMaker Server. + * @param int $ttl Time-to-live in seconds. Defaults to 840 seconds (14 minutes). + */ + public function setSessionCacheTtl(int $ttl = 840): void + { + $this->provider->sessionCache->setTtl($ttl); + } } From bbc8020c52debf04c97a719d7cb6a7170649d775 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 13:41:38 +0200 Subject: [PATCH 16/40] Updated documentation to reflect that specifying a session automatically enables retry on session invalidation --- src/FMDataAPI.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 7f76535..3f5c8fb 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -60,10 +60,11 @@ class FMDataAPI * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. * @param AbstractSessionCache|null $sessionCache Cache backend for persistent sessions. - * This stores the authentication token used for persistent session reuse. - * If omitted, persistent session caching is disabled and the library keeps the normal login/logout behavior. - * If specified, the host is used as the token scope and startCommunication() / endCommunication() - * will reuse session tokens between requests. + * This stores the FileMaker Data API session token for persistent session reuse. If omitted, + * persistent session caching is disabled and the library keeps the normal login/logout behavior. + * If specified, startCommunication() / endCommunication() will reuse session tokens between requests. + * Additionally, {@see self::setRetryOnAccessTokenInvalidation()} is automatically set to true, ensuring + * the library re-authenticates and retries the request if the cached token has expired on the FileMaker Server. */ public function __construct(string $solution, string $user, From 1185f84f68ff940b682e1cc40cc63568f1f620d3 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 13:57:22 +0200 Subject: [PATCH 17/40] Cleared up documentation of startCommunication and endCommunication --- src/FMDataAPI.php | 19 ++++++++++--------- src/Supporting/CommunicationProvider.php | 23 ++++++++++++++++++++--- src/Supporting/FileMakerLayout.php | 19 ++++++++++--------- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 3f5c8fb..eb40255 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -279,11 +279,12 @@ public function setThrowException(bool $value): void * By calling startCommunication() and endCommunication(), methods between them don't * log in and out every time, and it can expect faster operations. * - * When persistent sessions are not enabled, one authenticated session is kept during - * the current communication scope. + * Without a session cache, one authenticated session is kept for the duration of + * the current communication scope and discarded when endCommunication() is called. * - * When persistent sessions are enabled, the cached session token is reused if available. - * If there is no cached token, a new session is created and stored. + * With a session cache, the session token is persisted beyond the current communication + * scope and reused across requests. If no cached token is available, a new session is + * created and stored for future reuse. * * @throws Exception */ @@ -295,12 +296,12 @@ public function startCommunication(): void /** * Finish a communication scope. * - * When persistent sessions are not enabled, the authenticated session for the current - * communication scope is ended and the server session is logged out. + * Without a session cache, the authenticated session for the current communication + * scope is ended and the server session is logged out. * - * When persistent sessions are enabled, the cached token is renewed if it still matches - * the token held by this instance. If another worker has replaced the cached token in - * the meantime, only this instance's (now-stale) token is logged out, leaving the + * With a session cache, the cached token's TTL is renewed if it still matches the + * token held by this instance. If another process has replaced the cached token in + * the meantime, only this instance's now-stale token is logged out, leaving the * newer cached token intact. * * @throws Exception diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 49cf209..1af75aa 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -286,6 +286,15 @@ public function __construct(string $solution, } /** + * Start a communication scope with a shared authenticated session. + * + * Without a session cache, a new authenticated session is created and kept + * for the duration of the current communication scope. + * + * With a session cache, the cached session token is reused if available, + * avoiding a new login against the FileMaker Server. If no cached token is + * found, a new session is created and stored in the cache for future reuse. + * * @throws Exception In case of any error, an exception arises. */ public function startCommunication(): void @@ -299,9 +308,17 @@ public function startCommunication(): void } /** - * Close the scope. If persistent mode is on and our token is the one currently - * advertised in the cache, renew its TTL and leave it alive. Otherwise fall through - * to logout(), which will DELETE our (orphan) token at the server. + * Finish a communication scope. + * + * Without a session cache, the authenticated session is ended and the server + * session is logged out. + * + * With a session cache, if the token currently held by this instance matches + * the one in the cache, its TTL is renewed and the session is left alive. + * If another process has replaced the cached token in the meantime, this + * instance's now-stale token is considered orphaned and logged out at the + * server, leaving the newer cached token intact. + * * @throws Exception In case of any error, an exception arises. */ public function endCommunication(): void diff --git a/src/Supporting/FileMakerLayout.php b/src/Supporting/FileMakerLayout.php index efff41d..c602d74 100644 --- a/src/Supporting/FileMakerLayout.php +++ b/src/Supporting/FileMakerLayout.php @@ -48,11 +48,12 @@ public function __construct(CommunicationProvider|null $restAPI, * By calling startCommunication() and endCommunication(), methods between them don't * log in and out every time, and it can expect faster operations. * - * When persistent sessions are not enabled, one authenticated session is kept during - * the current communication scope. + * Without a session cache, one authenticated session is kept for the duration of + * the current communication scope and discarded when endCommunication() is called. * - * When persistent sessions are enabled, the cached session token is reused if available. - * If there is no cached token, a new session is created and stored. + * With a session cache, the session token is persisted beyond the current communication + * scope and reused across requests. If no cached token is available, a new session is + * created and stored for future reuse. * * @throws Exception */ @@ -64,12 +65,12 @@ public function startCommunication(): void /** * Finish a communication scope. * - * When persistent sessions are not enabled, the authenticated session for the current - * communication scope is ended and the server session is logged out. + * Without a session cache, the authenticated session for the current communication + * scope is ended and the server session is logged out. * - * When persistent sessions are enabled, the cached token is renewed if it still matches - * the token held by this instance. If another worker has replaced the cached token in - * the meantime, only this instance's (now-stale) token is logged out, leaving the + * With a session cache, the cached token's TTL is renewed if it still matches the + * token held by this instance. If another process has replaced the cached token in + * the meantime, only this instance's now-stale token is logged out, leaving the * newer cached token intact. * * @throws Exception From 238e8914c61c2305df489000799aee3dacd6d896 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 14:01:33 +0200 Subject: [PATCH 18/40] Clarified session cache behavior --- src/FMDataAPI.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index eb40255..29ec099 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -60,11 +60,13 @@ class FMDataAPI * If you use OAuth, "oAuthRequestId" and "oAuthIdentifier" keys have to be specified. * @param boolean $isUnitTest If it's set to true, the communication provider just works locally. * @param AbstractSessionCache|null $sessionCache Cache backend for persistent sessions. - * This stores the FileMaker Data API session token for persistent session reuse. If omitted, - * persistent session caching is disabled and the library keeps the normal login/logout behavior. - * If specified, startCommunication() / endCommunication() will reuse session tokens between requests. - * Additionally, {@see self::setRetryOnAccessTokenInvalidation()} is automatically set to true, ensuring - * the library re-authenticates and retries the request if the cached token has expired on the FileMaker Server. + * If omitted, the library logs in and out on every database operation, or once + * per communication scope when using startCommunication() / endCommunication(). + * If specified, session tokens are persisted and reused across requests via + * startCommunication() / endCommunication(), avoiding redundant logins against the FileMaker Server. + * When a session cache is specified, {@see self::setRetryOnAccessTokenInvalidation()} is + * automatically set to true, ensuring the library re-authenticates and retries the request if + * the cached token has expired on the FileMaker Server. */ public function __construct(string $solution, string $user, From 9fead75953f4ecf8905bdae9ebdf1bc8aa33f7eb Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:10:15 +0200 Subject: [PATCH 19/40] Fixed the cache key generation --- src/Supporting/CommunicationProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 1af75aa..447f59d 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -1115,7 +1115,7 @@ private function _createCurlHandle(string $url): CurlHandle private function cacheKey(): string { $data = [ - $this->url, + $this->user, $this->solution, (string)$this->port, $this->host, @@ -1124,6 +1124,6 @@ private function cacheKey(): string $hash = hash('sha256', implode('', $data)); - return "fm_token:$hash"; + return "fm_token_$hash"; } } From f95432f6689d1ced7647ab894e881f56604f1a1e Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:19:22 +0200 Subject: [PATCH 20/40] Added information that the APCu imlementation is not atomic --- src/SessionCache/ApcuSessionCache.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php index 5dc06f1..5a1dcf5 100644 --- a/src/SessionCache/ApcuSessionCache.php +++ b/src/SessionCache/ApcuSessionCache.php @@ -21,6 +21,12 @@ * only appropriate in environments where server memory access is properly * restricted. * + * Note that cache operations in this implementation are not atomic. While care + * has been taken to minimize the risk of race conditions, concurrent requests + * sharing the same cache key may occasionally result in redundant + * re-authentication against the FileMaker Server. This is considered an + * acceptable trade-off given the constraints of the current implementation. + * * @package INTER-Mediator\FileMakerServer\RESTAPI\SessionCache * @link https://github.com/msyk/FMDataAPI GitHub Repository * @version 36 From 57f4741f0c2b1f3c7014637b36dcc8f2a68c4d3f Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:20:33 +0200 Subject: [PATCH 21/40] Added suggestions to composer.json --- composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composer.json b/composer.json index 8bf5c83..9622588 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,11 @@ "phpunit/phpunit": "*", "phpstan/phpstan": "^2.0" }, + "suggest": { + "ext-apcu": "Required to use ApcuSessionCache.", + "psr/simple-cache": "Required to use Psr16SessionCache.", + "psr/cache": "Required to use Psr6SessionCache." + }, "autoload": { "psr-4": { "INTERMediator\\FileMakerServer\\RESTAPI\\": "src/" From 720aea21590fff24714827dca6d308b65c922439 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:23:00 +0200 Subject: [PATCH 22/40] Added adapters for converting PSR-6 and PSR-16 cache implementations to what is used in this project. --- src/SessionCache/Psr16SessionCache.php | 44 ++++++++++++++++++++++ src/SessionCache/Psr6SessionCache.php | 51 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/SessionCache/Psr16SessionCache.php create mode 100644 src/SessionCache/Psr6SessionCache.php diff --git a/src/SessionCache/Psr16SessionCache.php b/src/SessionCache/Psr16SessionCache.php new file mode 100644 index 0000000..c2b1899 --- /dev/null +++ b/src/SessionCache/Psr16SessionCache.php @@ -0,0 +1,44 @@ +cache = $cache; + } + + public function get(): ?string + { + try { + $value = $this->cache->get($this->cacheKey); + return is_string($value) ? $value : null; + } catch (InvalidArgumentException $e) { + return null; + } + } + + public function set(string $value): bool + { + try { + return $this->cache->set($this->cacheKey, $value, $this->ttl); + } catch (InvalidArgumentException $e) { + return false; + } + } + + public function delete(): bool + { + try { + return $this->cache->delete($this->cacheKey); + } catch (InvalidArgumentException $e) { + return false; + } + } +} diff --git a/src/SessionCache/Psr6SessionCache.php b/src/SessionCache/Psr6SessionCache.php new file mode 100644 index 0000000..a2454ef --- /dev/null +++ b/src/SessionCache/Psr6SessionCache.php @@ -0,0 +1,51 @@ +cache = $cache; + } + + public function get(): ?string + { + try { + $item = $this->cache->getItem($this->cacheKey); + if (!$item->isHit()) { + return null; + } + $value = $item->get(); + return is_string($value) ? $value : null; + } catch (InvalidArgumentException $e) { + return null; + } + } + + public function set(string $value): bool + { + try { + $item = $this->cache->getItem($this->cacheKey); + $item->set($value); + $item->expiresAfter($this->ttl); + return $this->cache->save($item); + } catch (InvalidArgumentException $e) { + return false; + } + } + + public function delete(): bool + { + try { + return $this->cache->deleteItem($this->cacheKey); + } catch (InvalidArgumentException $e) { + return false; + } + } +} From 39cb1af4cc0caae3780c712262b6b8a88580967e Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:27:53 +0200 Subject: [PATCH 23/40] Added resumeScopeAfterReauth property to start a communication session after reauthentication. --- src/Supporting/CommunicationProvider.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 447f59d..c584708 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -149,7 +149,11 @@ class CommunicationProvider * @ignore */ public bool $keepAuth = false; - + /** + * @var bool + * @ignore + */ + public bool $resumeScopeAfterReauth = false; /** * @var bool * @ignore @@ -600,6 +604,10 @@ public function login(): bool if ($this->sessionCache !== null) { $this->sessionCache->set($this->accessToken); } + if ($this->resumeScopeAfterReauth) { + $this->keepAuth = true; + $this->resumeScopeAfterReauth = false; + } return true; } } catch (Exception $e) { @@ -749,6 +757,10 @@ public function callRestAPI(array $params, } } catch (Exception $retry) { throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + } finally { + if ($resumeScope) { + $this->resumeScopeAfterReauth = true; + } } } From 1edfebfe89092f97169b053c9f5e26e3ae0f2d1b Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:28:16 +0200 Subject: [PATCH 24/40] Fixed setting cache key when no cache was provided --- src/Supporting/CommunicationProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index c584708..0cfff2e 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -286,7 +286,9 @@ public function __construct(string $solution, $this->fmDataSource = $fmDataSource; $this->sessionCache = $sessionCache; $this->errorCode = -1; - $this->sessionCache->setCacheKey($this->cacheKey()); + if ($this->sessionCache !== null) { + $this->sessionCache->setCacheKey($this->cacheKey()); + } } /** From be501ea3d7e64eff7b8aa209d132444472f8d8e0 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 15:59:12 +0200 Subject: [PATCH 25/40] Corrected name of doc reference --- src/SessionCache/ApcuSessionCache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php index 5a1dcf5..4c45fad 100644 --- a/src/SessionCache/ApcuSessionCache.php +++ b/src/SessionCache/ApcuSessionCache.php @@ -14,7 +14,7 @@ * * APCu is not enabled by default on all PHP installations. This implementation * is provided as a default for those that have APCu enabled. If APCu is not - * available, a custom implementation of {@see CacheInterface} should be used + * available, a custom implementation of {@see SessionCacheInterface} should be used * instead. * * As this cache stores sensitive FileMaker Data API session tokens, APCu is From 5e2d5e8619f2f24d07ec0cc28e9a15b0c6170c71 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Fri, 8 May 2026 16:02:29 +0200 Subject: [PATCH 26/40] Added exceptions on trying to modify session cache properties when not using a session cache. --- src/FMDataAPI.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/FMDataAPI.php b/src/FMDataAPI.php index 29ec099..6cd1f8e 100644 --- a/src/FMDataAPI.php +++ b/src/FMDataAPI.php @@ -482,9 +482,13 @@ public function setRetryOnAccessTokenInvalidation(bool $value = true): void * This default is sufficient for the vast majority of use cases. Only override * this if you have a specific reason to do so. * @param string $keyName The custom cache key name. + * @throws Exception If a session cache is not set, an exception is thrown. */ public function setSessionCacheKeyName(string $keyName): void { + if ($this->provider->sessionCache === null) { + throw new Exception("setSessionCacheKeyName() requires a session cache to be configured via the constructor."); + } $this->provider->sessionCache->setCacheKey($keyName); } @@ -499,9 +503,13 @@ public function setSessionCacheKeyName(string $keyName): void * FileMaker Data API session timeout of 15 minutes to ensure the cached token is * invalidated before it expires on the FileMaker Server. * @param int $ttl Time-to-live in seconds. Defaults to 840 seconds (14 minutes). + * @throws Exception If a session cache is not set, an exception is thrown. */ public function setSessionCacheTtl(int $ttl = 840): void { + if ($this->provider->sessionCache === null) { + throw new Exception("setSessionCacheTtl() requires a session cache to be configured via the constructor."); + } $this->provider->sessionCache->setTtl($ttl); } } From 5a2b8f3a4e892514a293af8fae2aa1ba4535558b Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 10:20:38 +0200 Subject: [PATCH 27/40] Now resumeScopeAfterReauth to false within endCommunication to not accidentally open a new communication session after reauthentication. --- src/Supporting/CommunicationProvider.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 0cfff2e..3baa86c 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -330,6 +330,7 @@ public function startCommunication(): void public function endCommunication(): void { $this->keepAuth = false; + $this->resumeScopeAfterReauth = false; if ($this->sessionCache !== null && $this->accessToken !== null) { if ($this->sessionCache->get() === $this->accessToken) { From a15fd28e5266dc40365e1b542bdb4cf022c46888 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 10:23:16 +0200 Subject: [PATCH 28/40] Removed unused property --- src/Supporting/CommunicationProvider.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 3baa86c..6690951 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -237,11 +237,6 @@ class CommunicationProvider * @ignore */ public AbstractSessionCache|null $sessionCache = null; - /** - * @var string - * @ignore - */ - public string $cacheKey; /** * CommunicationProvider constructor. From ccd4accaa4071c0f09f9a155e2f8f25bf70e2fac Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 10:29:58 +0200 Subject: [PATCH 29/40] Now `resumeScopeAfterReath` is only used when a login attempt failed, not when the actual command fails (since it's only in the login method that `keepAuth` is being set. --- src/Supporting/CommunicationProvider.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 6690951..fad96bf 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -747,18 +747,21 @@ public function callRestAPI(array $params, $this->keepAuth = false; try { $reauthed = $this->login(); - if ($reauthed && $resumeScope) { - $this->keepAuth = true; - } - if ($reauthed) { - $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); - } } catch (Exception $retry) { - throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); - } finally { if ($resumeScope) { $this->resumeScopeAfterReauth = true; } + throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + } + if ($reauthed) { + if ($resumeScope) { + $this->keepAuth = true; + } + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $retry) { + throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + } } } From c4b0685e1244aa143ba13a04bafb31f4d26d99a8 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 10:38:20 +0200 Subject: [PATCH 30/40] Fixed `resumeScopeAfterReauth` not being set if login() returned false --- src/Supporting/CommunicationProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index fad96bf..ccaa54c 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -748,10 +748,11 @@ public function callRestAPI(array $params, try { $reauthed = $this->login(); } catch (Exception $retry) { + throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + } finally { if ($resumeScope) { $this->resumeScopeAfterReauth = true; } - throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); } if ($reauthed) { if ($resumeScope) { From e87bc8b410b7b7c4eea7d039b3215e439dd7e7e6 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 16:09:27 +0200 Subject: [PATCH 31/40] Better logic to set resumeScopeAfterReath --- src/Supporting/CommunicationProvider.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index ccaa54c..208b078 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -747,12 +747,14 @@ public function callRestAPI(array $params, $this->keepAuth = false; try { $reauthed = $this->login(); + if (!$reauthed && $resumeScope) { + $this->resumeScopeAfterReauth = true; + } } catch (Exception $retry) { - throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); - } finally { if ($resumeScope) { $this->resumeScopeAfterReauth = true; } + throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); } if ($reauthed) { if ($resumeScope) { From d32d5c693496f90fb58aa242a8a013d307097b04 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 16:27:24 +0200 Subject: [PATCH 32/40] Removed PSR-6 and PSR-16 wrappers due to scope creep. Moved to a new branch instead. --- src/SessionCache/Psr16SessionCache.php | 44 ---------------------- src/SessionCache/Psr6SessionCache.php | 51 -------------------------- 2 files changed, 95 deletions(-) delete mode 100644 src/SessionCache/Psr16SessionCache.php delete mode 100644 src/SessionCache/Psr6SessionCache.php diff --git a/src/SessionCache/Psr16SessionCache.php b/src/SessionCache/Psr16SessionCache.php deleted file mode 100644 index c2b1899..0000000 --- a/src/SessionCache/Psr16SessionCache.php +++ /dev/null @@ -1,44 +0,0 @@ -cache = $cache; - } - - public function get(): ?string - { - try { - $value = $this->cache->get($this->cacheKey); - return is_string($value) ? $value : null; - } catch (InvalidArgumentException $e) { - return null; - } - } - - public function set(string $value): bool - { - try { - return $this->cache->set($this->cacheKey, $value, $this->ttl); - } catch (InvalidArgumentException $e) { - return false; - } - } - - public function delete(): bool - { - try { - return $this->cache->delete($this->cacheKey); - } catch (InvalidArgumentException $e) { - return false; - } - } -} diff --git a/src/SessionCache/Psr6SessionCache.php b/src/SessionCache/Psr6SessionCache.php deleted file mode 100644 index a2454ef..0000000 --- a/src/SessionCache/Psr6SessionCache.php +++ /dev/null @@ -1,51 +0,0 @@ -cache = $cache; - } - - public function get(): ?string - { - try { - $item = $this->cache->getItem($this->cacheKey); - if (!$item->isHit()) { - return null; - } - $value = $item->get(); - return is_string($value) ? $value : null; - } catch (InvalidArgumentException $e) { - return null; - } - } - - public function set(string $value): bool - { - try { - $item = $this->cache->getItem($this->cacheKey); - $item->set($value); - $item->expiresAfter($this->ttl); - return $this->cache->save($item); - } catch (InvalidArgumentException $e) { - return false; - } - } - - public function delete(): bool - { - try { - return $this->cache->deleteItem($this->cacheKey); - } catch (InvalidArgumentException $e) { - return false; - } - } -} From 1a93949cbe969fae0161c1e6843b65b3eb6f9f4f Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 16:31:32 +0200 Subject: [PATCH 33/40] Set implode separator to null byte (\0) --- src/Supporting/CommunicationProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 208b078..484058e 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -1138,7 +1138,7 @@ private function cacheKey(): string $this->protocol, ]; - $hash = hash('sha256', implode('', $data)); + $hash = hash('sha256', implode("\0", $data)); return "fm_token_$hash"; } From 4b605060223649603faff477e95a6779aedf4986 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Mon, 11 May 2026 16:54:55 +0200 Subject: [PATCH 34/40] Removed misleading composer json suggestions of removed files. --- composer.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 9622588..af97207 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,7 @@ "phpstan/phpstan": "^2.0" }, "suggest": { - "ext-apcu": "Required to use ApcuSessionCache.", - "psr/simple-cache": "Required to use Psr16SessionCache.", - "psr/cache": "Required to use Psr6SessionCache." + "ext-apcu": "Required to use ApcuSessionCache." }, "autoload": { "psr-4": { From f33f01e3bb831b1221e94ce873e0b371cad3c10e Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 12 May 2026 14:27:44 +0200 Subject: [PATCH 35/40] Fixed logic for setting keepAuth and resumeScopeAfterReauth --- src/Supporting/CommunicationProvider.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 484058e..f6d9f9a 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -747,19 +747,16 @@ public function callRestAPI(array $params, $this->keepAuth = false; try { $reauthed = $this->login(); - if (!$reauthed && $resumeScope) { - $this->resumeScopeAfterReauth = true; + if ($reauthed) { + $this->keepAuth = $resumeScope; + } else { + $this->resumeScopeAfterReauth = $resumeScope; } } catch (Exception $retry) { - if ($resumeScope) { - $this->resumeScopeAfterReauth = true; - } + $this->resumeScopeAfterReauth = $resumeScope; throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); } if ($reauthed) { - if ($resumeScope) { - $this->keepAuth = true; - } try { $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); } catch (Exception $retry) { From 79bd44e5778f0d20c9264b17d42aed6ed9078e67 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 12 May 2026 14:50:02 +0200 Subject: [PATCH 36/40] Fixed so retrieving a cached session token now sets keepAuth when it should. --- src/Supporting/CommunicationProvider.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index f6d9f9a..5b955e3 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -574,6 +574,10 @@ public function login(): bool $cached = $this->sessionCache->get(); if ($cached !== null) { $this->accessToken = $cached; + if ($this->resumeScopeAfterReauth) { + $this->keepAuth = true; + $this->resumeScopeAfterReauth = false; + } return true; } } From 51d9f096c5ce61a938636a1866c3c8c478a57c80 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Tue, 12 May 2026 17:07:47 +0200 Subject: [PATCH 37/40] Fixed documentation of ApcuSessionCache --- src/SessionCache/ApcuSessionCache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php index 4c45fad..3e147df 100644 --- a/src/SessionCache/ApcuSessionCache.php +++ b/src/SessionCache/ApcuSessionCache.php @@ -14,7 +14,7 @@ * * APCu is not enabled by default on all PHP installations. This implementation * is provided as a default for those that have APCu enabled. If APCu is not - * available, a custom implementation of {@see SessionCacheInterface} should be used + * available, a custom extension of {@see AbstractSessionCache} should be used * instead. * * As this cache stores sensitive FileMaker Data API session tokens, APCu is From a4d9665df3673b12395dc3a2ca742135711e39ff Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 13 May 2026 09:39:56 +0200 Subject: [PATCH 38/40] Moved around logic in callRestAPI to make it clearer to read --- src/Supporting/CommunicationProvider.php | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Supporting/CommunicationProvider.php b/src/Supporting/CommunicationProvider.php index 5b955e3..bed65c2 100644 --- a/src/Supporting/CommunicationProvider.php +++ b/src/Supporting/CommunicationProvider.php @@ -750,22 +750,21 @@ public function callRestAPI(array $params, $this->accessToken = null; $this->keepAuth = false; try { - $reauthed = $this->login(); - if ($reauthed) { - $this->keepAuth = $resumeScope; - } else { + if (!$this->login()) { $this->resumeScopeAfterReauth = $resumeScope; + return; } - } catch (Exception $retry) { + } catch (Exception $e) { $this->resumeScopeAfterReauth = $resumeScope; - throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); + throw new Exception($e->getMessage(), $e->getCode(), $firstAttempt); } - if ($reauthed) { - try { - $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); - } catch (Exception $retry) { - throw new Exception($retry->getMessage(), $retry->getCode(), $firstAttempt); - } + + // The login was successful here — retry the original call + $this->keepAuth = $resumeScope; + try { + $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath); + } catch (Exception $e) { + throw new Exception($e->getMessage(), $e->getCode(), $firstAttempt); } } From 9c34cee7f85de5a494b3f87d9038238071590f54 Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 13 May 2026 13:10:56 +0200 Subject: [PATCH 39/40] Improved clarity of ApcuSessionCache suggestion --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index af97207..41edd5d 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "phpstan/phpstan": "^2.0" }, "suggest": { - "ext-apcu": "Required to use ApcuSessionCache." + "ext-apcu": "Optional: required only by the built-in ApcuSessionCache backend." }, "autoload": { "psr-4": { From 42f2e766baf9331a93669dedbb1dc0d98e9e1c3a Mon Sep 17 00:00:00 2001 From: filiptorphage-mjuk Date: Wed, 13 May 2026 14:56:39 +0200 Subject: [PATCH 40/40] Enforced strict types declaration in ApcuSessionCache. --- src/SessionCache/ApcuSessionCache.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SessionCache/ApcuSessionCache.php b/src/SessionCache/ApcuSessionCache.php index 3e147df..37d8dc9 100644 --- a/src/SessionCache/ApcuSessionCache.php +++ b/src/SessionCache/ApcuSessionCache.php @@ -1,5 +1,7 @@