From 0feb70903d7f7cff4b827e467a44c1d7c9d7c5b0 Mon Sep 17 00:00:00 2001 From: yankewei Date: Thu, 26 Mar 2026 14:16:18 +0800 Subject: [PATCH 1/2] Add global PSR-20 clock support --- composer.json | 1 + src/Config.php | 31 ++++++++++++++ src/Http/Auth/AccessTokenAuthenticator.php | 3 +- src/Traits/OAuth2/AuthorizationCodeGrant.php | 19 +++++---- src/Traits/OAuth2/ClientCredentialsGrant.php | 11 +++-- .../Oauth2/AuthCodeFlowConnectorTest.php | 40 +++++++++++++++++++ .../ClientCredentialsFlowConnectorTest.php | 22 ++++++++++ tests/Fixtures/Clock/FixedClock.php | 27 +++++++++++++ tests/Unit/ConfigTest.php | 18 +++++++++ .../Oauth2/AccessTokenAuthenticatorTest.php | 17 ++++++++ 10 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/Clock/FixedClock.php diff --git a/composer.json b/composer.json index 4626f630..7375bdc3 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "guzzlehttp/guzzle": "^7.6", "guzzlehttp/promises": "^1.5 || ^2.0", "guzzlehttp/psr7": "^2.0", + "psr/clock": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0" }, diff --git a/src/Config.php b/src/Config.php index f0d7f4a7..7e0f3696 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,8 +4,10 @@ namespace Saloon; +use DateTimeImmutable; use Saloon\Enums\PipeOrder; use Saloon\Contracts\Sender; +use Psr\Clock\ClockInterface; use Saloon\Http\PendingRequest; use Saloon\Http\Senders\GuzzleSender; use Saloon\Helpers\MiddlewarePipeline; @@ -52,6 +54,11 @@ final class Config */ private static bool $preventStrayRequests = false; + /** + * Global clock used by built-in time-aware features. + */ + private static ?ClockInterface $clock = null; + /** * Write a custom sender resolver */ @@ -70,6 +77,30 @@ public static function getDefaultSender(): Sender return is_callable($senderResolver) ? $senderResolver() : new self::$defaultSender; } + /** + * Set the global package clock. + */ + public static function setClock(?ClockInterface $clock): void + { + self::$clock = $clock; + } + + /** + * Get the global package clock. + */ + public static function getClock(): ?ClockInterface + { + return self::$clock; + } + + /** + * Resolve the current time. + */ + public static function now(): DateTimeImmutable + { + return self::$clock?->now() ?? new DateTimeImmutable; + } + /** * Update global middleware */ diff --git a/src/Http/Auth/AccessTokenAuthenticator.php b/src/Http/Auth/AccessTokenAuthenticator.php index 17569f29..2af02c4f 100644 --- a/src/Http/Auth/AccessTokenAuthenticator.php +++ b/src/Http/Auth/AccessTokenAuthenticator.php @@ -4,6 +4,7 @@ namespace Saloon\Http\Auth; +use Saloon\Config; use DateTimeImmutable; use Saloon\Http\PendingRequest; use Saloon\Contracts\OAuthAuthenticator; @@ -38,7 +39,7 @@ public function hasExpired(): bool return false; } - return $this->expiresAt->getTimestamp() <= (new DateTimeImmutable)->getTimestamp(); + return $this->expiresAt->getTimestamp() <= Config::now()->getTimestamp(); } /** diff --git a/src/Traits/OAuth2/AuthorizationCodeGrant.php b/src/Traits/OAuth2/AuthorizationCodeGrant.php index a6d9a6a8..9b378396 100644 --- a/src/Traits/OAuth2/AuthorizationCodeGrant.php +++ b/src/Traits/OAuth2/AuthorizationCodeGrant.php @@ -5,6 +5,7 @@ namespace Saloon\Traits\OAuth2; use DateInterval; +use Saloon\Config; use DateTimeImmutable; use Saloon\Http\Request; use Saloon\Http\Response; @@ -81,15 +82,17 @@ public function getAuthorizationUrl(array $scopes = [], ?string $state = null, s */ public function getAccessToken(string $code, ?string $state = null, ?string $expectedState = null, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response { - $this->oauthConfig()->validate(); + $oauthConfig = $this->oauthConfig(); + + $oauthConfig->validate(); if (! empty($state) && ! empty($expectedState) && $state !== $expectedState) { throw new InvalidStateException; } - $request = $this->resolveAccessTokenRequest($code, $this->oauthConfig()); + $request = $this->resolveAccessTokenRequest($code, $oauthConfig); - $request = $this->oauthConfig()->invokeRequestModifier($request); + $request = $oauthConfig->invokeRequestModifier($request); if (is_callable($requestModifier)) { $requestModifier($request); @@ -117,7 +120,9 @@ public function getAccessToken(string $code, ?string $state = null, ?string $exp */ public function refreshAccessToken(OAuthAuthenticator|string $refreshToken, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response { - $this->oauthConfig()->validate(); + $oauthConfig = $this->oauthConfig(); + + $oauthConfig->validate(); if ($refreshToken instanceof OAuthAuthenticator) { if ($refreshToken->isNotRefreshable()) { @@ -127,9 +132,9 @@ public function refreshAccessToken(OAuthAuthenticator|string $refreshToken, bool $refreshToken = $refreshToken->getRefreshToken(); } - $request = $this->resolveRefreshTokenRequest($this->oauthConfig(), $refreshToken); + $request = $this->resolveRefreshTokenRequest($oauthConfig, $refreshToken); - $request = $this->oauthConfig()->invokeRequestModifier($request); + $request = $oauthConfig->invokeRequestModifier($request); if (is_callable($requestModifier)) { $requestModifier($request); @@ -159,7 +164,7 @@ protected function createOAuthAuthenticatorFromResponse(Response $response, ?str $expiresAt = null; if (isset($responseData->expires_in) && is_numeric($responseData->expires_in)) { - $expiresAt = (new DateTimeImmutable)->add( + $expiresAt = Config::now()->add( DateInterval::createFromDateString((int)$responseData->expires_in . ' seconds') ); } diff --git a/src/Traits/OAuth2/ClientCredentialsGrant.php b/src/Traits/OAuth2/ClientCredentialsGrant.php index 213e999c..ef2145ab 100644 --- a/src/Traits/OAuth2/ClientCredentialsGrant.php +++ b/src/Traits/OAuth2/ClientCredentialsGrant.php @@ -5,6 +5,7 @@ namespace Saloon\Traits\OAuth2; use DateInterval; +use Saloon\Config; use DateTimeImmutable; use Saloon\Http\Request; use Saloon\Http\Response; @@ -32,11 +33,13 @@ trait ClientCredentialsGrant */ public function getAccessToken(array $scopes = [], string $scopeSeparator = ' ', bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response { - $this->oauthConfig()->validate(withRedirectUrl: false); + $oauthConfig = $this->oauthConfig(); - $request = $this->resolveAccessTokenRequest($this->oauthConfig(), $scopes, $scopeSeparator); + $oauthConfig->validate(withRedirectUrl: false); - $request = $this->oauthConfig()->invokeRequestModifier($request); + $request = $this->resolveAccessTokenRequest($oauthConfig, $scopes, $scopeSeparator); + + $request = $oauthConfig->invokeRequestModifier($request); if (is_callable($requestModifier)) { $requestModifier($request); @@ -64,7 +67,7 @@ protected function createOAuthAuthenticatorFromResponse(Response $response): OAu $expiresAt = null; if (isset($responseData->expires_in) && is_numeric($responseData->expires_in)) { - $expiresAt = (new DateTimeImmutable)->add( + $expiresAt = Config::now()->add( DateInterval::createFromDateString((int)$responseData->expires_in . ' seconds') ); } diff --git a/tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php index 0e4f6e83..237e8e6e 100644 --- a/tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php +++ b/tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use Saloon\Config; use Saloon\Http\Request; use Saloon\Http\Response; use Saloon\Tests\Helpers\Date; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use Saloon\Http\OAuth2\GetUserRequest; +use Saloon\Tests\Fixtures\Clock\FixedClock; use Saloon\Exceptions\InvalidStateException; use Saloon\Http\OAuth2\GetAccessTokenRequest; use Saloon\Http\Auth\AccessTokenAuthenticator; @@ -22,6 +24,10 @@ use Saloon\Tests\Fixtures\Connectors\CustomResponseOAuth2Connector; use Saloon\Tests\Fixtures\Requests\OAuth\CustomRefreshTokenRequest; +afterEach(function () { + Config::setClock(null); +}); + test('you can get the redirect url from a connector', function () { $connector = new OAuth2Connector; @@ -109,6 +115,40 @@ expect($authenticator->getExpiresAt())->toBeInstanceOf(DateTimeImmutable::class); }); +test('oauth access tokens derive expiry from the global clock', function () { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + Config::setClock(new FixedClock($now)); + + $connector = new OAuth2Connector; + $connector->withMockClient($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds')); +}); + +test('custom oauth authenticators use the global clock for expiry semantics', function () { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + Config::setClock(new FixedClock($now)); + + $connector = new CustomResponseOAuth2Connector('hello'); + $connector->withMockClient($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + expect($authenticator)->toBeInstanceOf(CustomOAuthAuthenticator::class); + expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds')); + expect($authenticator->hasExpired())->toBeFalse(); +}); + test('you can tap into the access token request and modify it', function () { $mockClient = new MockClient([ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), diff --git a/tests/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php b/tests/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php index c6f4a892..e7b175cc 100644 --- a/tests/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php +++ b/tests/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); +use Saloon\Config; use Saloon\Http\Request; use Saloon\Http\Response; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +use Saloon\Tests\Fixtures\Clock\FixedClock; use Saloon\Http\Auth\AccessTokenAuthenticator; use Saloon\Exceptions\OAuthConfigValidationException; use Saloon\Tests\Fixtures\Connectors\ClientCredentialsConnector; @@ -14,6 +16,10 @@ use Saloon\Tests\Fixtures\Connectors\CustomRequestClientCredentialsConnector; use Saloon\Tests\Fixtures\Requests\OAuth\CustomClientCredentialsAccessTokenRequest; +afterEach(function () { + Config::setClock(null); +}); + test('you can get the authenticator from the connector', function () { $mockClient = new MockClient([ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), @@ -40,6 +46,22 @@ ]); }); +test('client credentials tokens derive expiry from the global clock', function () { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + Config::setClock(new FixedClock($now)); + + $connector = new ClientCredentialsConnector; + $connector->withMockClient($mockClient); + + $authenticator = $connector->getAccessToken(); + + expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds')); +}); + test('you can get the response instead of the authenticator', function () { $mockClient = new MockClient([ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), diff --git a/tests/Fixtures/Clock/FixedClock.php b/tests/Fixtures/Clock/FixedClock.php new file mode 100644 index 00000000..0d9a9414 --- /dev/null +++ b/tests/Fixtures/Clock/FixedClock.php @@ -0,0 +1,27 @@ +now; + } +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index ee0f22db..ee4ce124 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -8,6 +8,7 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use Saloon\Http\Senders\GuzzleSender; +use Saloon\Tests\Fixtures\Clock\FixedClock; use Saloon\Exceptions\StrayRequestException; use Saloon\Tests\Fixtures\Senders\ArraySender; use Saloon\Tests\Fixtures\Requests\UserRequest; @@ -16,6 +17,9 @@ afterEach(function () { Config::clearGlobalMiddleware(); Config::$defaultSender = GuzzleSender::class; + Config::setSenderResolver(null); + Config::setClock(null); + Config::allowStrayRequests(); }); test('the config can specify global middleware', function () { @@ -74,6 +78,20 @@ expect($sender)->toBeInstanceOf(GuzzleSender::class); }); +test('you can configure a global clock and resolve now', function () { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $clock = new FixedClock($now); + + Config::setClock($clock); + + expect(Config::getClock())->toBe($clock); + expect(Config::now())->toEqual($now); + + Config::setClock(null); + + expect(Config::getClock())->toBeNull(); +}); + test('you can prevent stray api requests', function () { Config::preventStrayRequests(); diff --git a/tests/Unit/Oauth2/AccessTokenAuthenticatorTest.php b/tests/Unit/Oauth2/AccessTokenAuthenticatorTest.php index f42ef84c..99bbb568 100644 --- a/tests/Unit/Oauth2/AccessTokenAuthenticatorTest.php +++ b/tests/Unit/Oauth2/AccessTokenAuthenticatorTest.php @@ -2,9 +2,15 @@ declare(strict_types=1); +use Saloon\Config; use Saloon\Tests\Helpers\Date; +use Saloon\Tests\Fixtures\Clock\FixedClock; use Saloon\Http\Auth\AccessTokenAuthenticator; +afterEach(function () { + Config::setClock(null); +}); + it('can return if it has expired or not', function () { $accessToken = 'access'; $refreshToken = 'refresh'; @@ -44,3 +50,14 @@ expect($authenticator->isRefreshable())->toBeTrue(); expect($authenticator->isNotRefreshable())->toBeFalse(); }); + +test('it can use the global clock for expiry checks', function () { + $expiresAt = new DateTimeImmutable('2026-01-01T01:00:00+00:00'); + + Config::setClock(new FixedClock(new DateTimeImmutable('2026-01-01T02:00:00+00:00'))); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh', $expiresAt); + + expect($authenticator->hasExpired())->toBeTrue(); + expect($authenticator->hasNotExpired())->toBeFalse(); +}); From f14916abada98e448a1664cf6709f06ae389c93b Mon Sep 17 00:00:00 2001 From: yankewei Date: Thu, 26 Mar 2026 17:11:46 +0800 Subject: [PATCH 2/2] Fix code style workflow for fork pull requests --- .github/workflows/php-cs-fixer.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index e79fd46f..9ea44641 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -22,7 +22,15 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Checkout branch + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - name: Checkout pull request merge ref + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,9 +42,15 @@ jobs: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Run PHP CS Fixer + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: ./vendor/bin/php-cs-fixer fix --allow-risky=yes + - name: Check PHP CS Fixer + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: ./vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run --diff + - name: Commit Changes + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: 🪄 Code Style Fixes