Skip to content

Commit 6f69295

Browse files
committed
Add global PSR-20 clock support
1 parent 96cedff commit 6f69295

10 files changed

Lines changed: 177 additions & 12 deletions

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"guzzlehttp/guzzle": "^7.6",
2424
"guzzlehttp/promises": "^1.5 || ^2.0",
2525
"guzzlehttp/psr7": "^2.0",
26+
"psr/clock": "^1.0",
2627
"psr/http-factory": "^1.0",
2728
"psr/http-message": "^1.1 || ^2.0"
2829
},

src/Config.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Saloon;
66

7+
use DateTimeImmutable;
8+
use Psr\Clock\ClockInterface;
79
use Saloon\Enums\PipeOrder;
810
use Saloon\Contracts\Sender;
911
use Saloon\Http\PendingRequest;
@@ -52,6 +54,11 @@ final class Config
5254
*/
5355
private static bool $preventStrayRequests = false;
5456

57+
/**
58+
* Global clock used by built-in time-aware features.
59+
*/
60+
private static ?ClockInterface $clock = null;
61+
5562
/**
5663
* Write a custom sender resolver
5764
*/
@@ -70,6 +77,30 @@ public static function getDefaultSender(): Sender
7077
return is_callable($senderResolver) ? $senderResolver() : new self::$defaultSender;
7178
}
7279

80+
/**
81+
* Set the global package clock.
82+
*/
83+
public static function setClock(?ClockInterface $clock): void
84+
{
85+
self::$clock = $clock;
86+
}
87+
88+
/**
89+
* Get the global package clock.
90+
*/
91+
public static function getClock(): ?ClockInterface
92+
{
93+
return self::$clock;
94+
}
95+
96+
/**
97+
* Resolve the current time.
98+
*/
99+
public static function now(): DateTimeImmutable
100+
{
101+
return self::$clock?->now() ?? new DateTimeImmutable;
102+
}
103+
73104
/**
74105
* Update global middleware
75106
*/

src/Http/Auth/AccessTokenAuthenticator.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Saloon\Http\Auth;
66

77
use DateTimeImmutable;
8+
use Saloon\Config;
89
use Saloon\Http\PendingRequest;
910
use Saloon\Contracts\OAuthAuthenticator;
1011

@@ -38,7 +39,7 @@ public function hasExpired(): bool
3839
return false;
3940
}
4041

41-
return $this->expiresAt->getTimestamp() <= (new DateTimeImmutable)->getTimestamp();
42+
return $this->expiresAt->getTimestamp() <= Config::now()->getTimestamp();
4243
}
4344

4445
/**

src/Traits/OAuth2/AuthorizationCodeGrant.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use DateInterval;
88
use DateTimeImmutable;
9+
use Saloon\Config;
910
use Saloon\Http\Request;
1011
use Saloon\Http\Response;
1112
use InvalidArgumentException;
@@ -81,15 +82,17 @@ public function getAuthorizationUrl(array $scopes = [], ?string $state = null, s
8182
*/
8283
public function getAccessToken(string $code, ?string $state = null, ?string $expectedState = null, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response
8384
{
84-
$this->oauthConfig()->validate();
85+
$oauthConfig = $this->oauthConfig();
86+
87+
$oauthConfig->validate();
8588

8689
if (! empty($state) && ! empty($expectedState) && $state !== $expectedState) {
8790
throw new InvalidStateException;
8891
}
8992

90-
$request = $this->resolveAccessTokenRequest($code, $this->oauthConfig());
93+
$request = $this->resolveAccessTokenRequest($code, $oauthConfig);
9194

92-
$request = $this->oauthConfig()->invokeRequestModifier($request);
95+
$request = $oauthConfig->invokeRequestModifier($request);
9396

9497
if (is_callable($requestModifier)) {
9598
$requestModifier($request);
@@ -117,7 +120,9 @@ public function getAccessToken(string $code, ?string $state = null, ?string $exp
117120
*/
118121
public function refreshAccessToken(OAuthAuthenticator|string $refreshToken, bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response
119122
{
120-
$this->oauthConfig()->validate();
123+
$oauthConfig = $this->oauthConfig();
124+
125+
$oauthConfig->validate();
121126

122127
if ($refreshToken instanceof OAuthAuthenticator) {
123128
if ($refreshToken->isNotRefreshable()) {
@@ -127,9 +132,9 @@ public function refreshAccessToken(OAuthAuthenticator|string $refreshToken, bool
127132
$refreshToken = $refreshToken->getRefreshToken();
128133
}
129134

130-
$request = $this->resolveRefreshTokenRequest($this->oauthConfig(), $refreshToken);
135+
$request = $this->resolveRefreshTokenRequest($oauthConfig, $refreshToken);
131136

132-
$request = $this->oauthConfig()->invokeRequestModifier($request);
137+
$request = $oauthConfig->invokeRequestModifier($request);
133138

134139
if (is_callable($requestModifier)) {
135140
$requestModifier($request);
@@ -159,7 +164,7 @@ protected function createOAuthAuthenticatorFromResponse(Response $response, ?str
159164
$expiresAt = null;
160165

161166
if (isset($responseData->expires_in) && is_numeric($responseData->expires_in)) {
162-
$expiresAt = (new DateTimeImmutable)->add(
167+
$expiresAt = Config::now()->add(
163168
DateInterval::createFromDateString((int)$responseData->expires_in . ' seconds')
164169
);
165170
}

src/Traits/OAuth2/ClientCredentialsGrant.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use DateInterval;
88
use DateTimeImmutable;
9+
use Saloon\Config;
910
use Saloon\Http\Request;
1011
use Saloon\Http\Response;
1112
use Saloon\Helpers\OAuth2\OAuthConfig;
@@ -32,11 +33,13 @@ trait ClientCredentialsGrant
3233
*/
3334
public function getAccessToken(array $scopes = [], string $scopeSeparator = ' ', bool $returnResponse = false, ?callable $requestModifier = null): OAuthAuthenticator|Response
3435
{
35-
$this->oauthConfig()->validate(withRedirectUrl: false);
36+
$oauthConfig = $this->oauthConfig();
3637

37-
$request = $this->resolveAccessTokenRequest($this->oauthConfig(), $scopes, $scopeSeparator);
38+
$oauthConfig->validate(withRedirectUrl: false);
3839

39-
$request = $this->oauthConfig()->invokeRequestModifier($request);
40+
$request = $this->resolveAccessTokenRequest($oauthConfig, $scopes, $scopeSeparator);
41+
42+
$request = $oauthConfig->invokeRequestModifier($request);
4043

4144
if (is_callable($requestModifier)) {
4245
$requestModifier($request);
@@ -64,7 +67,7 @@ protected function createOAuthAuthenticatorFromResponse(Response $response): OAu
6467
$expiresAt = null;
6568

6669
if (isset($responseData->expires_in) && is_numeric($responseData->expires_in)) {
67-
$expiresAt = (new DateTimeImmutable)->add(
70+
$expiresAt = Config::now()->add(
6871
DateInterval::createFromDateString((int)$responseData->expires_in . ' seconds')
6972
);
7073
}

tests/Feature/Oauth2/AuthCodeFlowConnectorTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use Saloon\Config;
56
use Saloon\Http\Request;
67
use Saloon\Http\Response;
78
use Saloon\Tests\Helpers\Date;
@@ -17,11 +18,16 @@
1718
use Saloon\Tests\Fixtures\Connectors\NoConfigAuthCodeConnector;
1819
use Saloon\Tests\Fixtures\Requests\OAuth\CustomOAuthUserRequest;
1920
use Saloon\Tests\Fixtures\Authenticators\CustomOAuthAuthenticator;
21+
use Saloon\Tests\Fixtures\Clock\FixedClock;
2022
use Saloon\Tests\Fixtures\Connectors\CustomRequestOAuth2Connector;
2123
use Saloon\Tests\Fixtures\Requests\OAuth\CustomAccessTokenRequest;
2224
use Saloon\Tests\Fixtures\Connectors\CustomResponseOAuth2Connector;
2325
use Saloon\Tests\Fixtures\Requests\OAuth\CustomRefreshTokenRequest;
2426

27+
afterEach(function () {
28+
Config::setClock(null);
29+
});
30+
2531
test('you can get the redirect url from a connector', function () {
2632
$connector = new OAuth2Connector;
2733

@@ -109,6 +115,40 @@
109115
expect($authenticator->getExpiresAt())->toBeInstanceOf(DateTimeImmutable::class);
110116
});
111117

118+
test('oauth access tokens derive expiry from the global clock', function () {
119+
$now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
120+
$mockClient = new MockClient([
121+
MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
122+
]);
123+
124+
Config::setClock(new FixedClock($now));
125+
126+
$connector = new OAuth2Connector;
127+
$connector->withMockClient($mockClient);
128+
129+
$authenticator = $connector->getAccessToken('code');
130+
131+
expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds'));
132+
});
133+
134+
test('custom oauth authenticators use the global clock for expiry semantics', function () {
135+
$now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
136+
$mockClient = new MockClient([
137+
MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
138+
]);
139+
140+
Config::setClock(new FixedClock($now));
141+
142+
$connector = new CustomResponseOAuth2Connector('hello');
143+
$connector->withMockClient($mockClient);
144+
145+
$authenticator = $connector->getAccessToken('code');
146+
147+
expect($authenticator)->toBeInstanceOf(CustomOAuthAuthenticator::class);
148+
expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds'));
149+
expect($authenticator->hasExpired())->toBeFalse();
150+
});
151+
112152
test('you can tap into the access token request and modify it', function () {
113153
$mockClient = new MockClient([
114154
MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),

tests/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@
22

33
declare(strict_types=1);
44

5+
use Saloon\Config;
56
use Saloon\Http\Request;
67
use Saloon\Http\Response;
78
use Saloon\Http\Faking\MockClient;
89
use Saloon\Http\Faking\MockResponse;
910
use Saloon\Http\Auth\AccessTokenAuthenticator;
1011
use Saloon\Exceptions\OAuthConfigValidationException;
12+
use Saloon\Tests\Fixtures\Clock\FixedClock;
1113
use Saloon\Tests\Fixtures\Connectors\ClientCredentialsConnector;
1214
use Saloon\Tests\Fixtures\Connectors\NoConfigClientCredentialsConnector;
1315
use Saloon\Tests\Fixtures\Connectors\ClientCredentialsBasicAuthConnector;
1416
use Saloon\Tests\Fixtures\Connectors\CustomRequestClientCredentialsConnector;
1517
use Saloon\Tests\Fixtures\Requests\OAuth\CustomClientCredentialsAccessTokenRequest;
1618

19+
afterEach(function () {
20+
Config::setClock(null);
21+
});
22+
1723
test('you can get the authenticator from the connector', function () {
1824
$mockClient = new MockClient([
1925
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
@@ -40,6 +46,22 @@
4046
]);
4147
});
4248

49+
test('client credentials tokens derive expiry from the global clock', function () {
50+
$now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
51+
$mockClient = new MockClient([
52+
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
53+
]);
54+
55+
Config::setClock(new FixedClock($now));
56+
57+
$connector = new ClientCredentialsConnector;
58+
$connector->withMockClient($mockClient);
59+
60+
$authenticator = $connector->getAccessToken();
61+
62+
expect($authenticator->getExpiresAt())->toEqual($now->modify('+3600 seconds'));
63+
});
64+
4365
test('you can get the response instead of the authenticator', function () {
4466
$mockClient = new MockClient([
4567
MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Tests\Fixtures\Clock;
6+
7+
use DateTimeImmutable;
8+
use Psr\Clock\ClockInterface;
9+
10+
final class FixedClock implements ClockInterface
11+
{
12+
/**
13+
* Constructor
14+
*/
15+
public function __construct(protected DateTimeImmutable $now)
16+
{
17+
//
18+
}
19+
20+
/**
21+
* Get the current time.
22+
*/
23+
public function now(): DateTimeImmutable
24+
{
25+
return $this->now;
26+
}
27+
}

tests/Unit/ConfigTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111
use Saloon\Exceptions\StrayRequestException;
1212
use Saloon\Tests\Fixtures\Senders\ArraySender;
1313
use Saloon\Tests\Fixtures\Requests\UserRequest;
14+
use Saloon\Tests\Fixtures\Clock\FixedClock;
1415
use Saloon\Tests\Fixtures\Connectors\TestConnector;
1516

1617
afterEach(function () {
1718
Config::clearGlobalMiddleware();
1819
Config::$defaultSender = GuzzleSender::class;
20+
Config::setSenderResolver(null);
21+
Config::setClock(null);
22+
Config::allowStrayRequests();
1923
});
2024

2125
test('the config can specify global middleware', function () {
@@ -74,6 +78,20 @@
7478
expect($sender)->toBeInstanceOf(GuzzleSender::class);
7579
});
7680

81+
test('you can configure a global clock and resolve now', function () {
82+
$now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
83+
$clock = new FixedClock($now);
84+
85+
Config::setClock($clock);
86+
87+
expect(Config::getClock())->toBe($clock);
88+
expect(Config::now())->toEqual($now);
89+
90+
Config::setClock(null);
91+
92+
expect(Config::getClock())->toBeNull();
93+
});
94+
7795
test('you can prevent stray api requests', function () {
7896
Config::preventStrayRequests();
7997

tests/Unit/Oauth2/AccessTokenAuthenticatorTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
declare(strict_types=1);
44

5+
use Saloon\Config;
56
use Saloon\Tests\Helpers\Date;
67
use Saloon\Http\Auth\AccessTokenAuthenticator;
8+
use Saloon\Tests\Fixtures\Clock\FixedClock;
9+
10+
afterEach(function () {
11+
Config::setClock(null);
12+
});
713

814
it('can return if it has expired or not', function () {
915
$accessToken = 'access';
@@ -44,3 +50,14 @@
4450
expect($authenticator->isRefreshable())->toBeTrue();
4551
expect($authenticator->isNotRefreshable())->toBeFalse();
4652
});
53+
54+
test('it can use the global clock for expiry checks', function () {
55+
$expiresAt = new DateTimeImmutable('2026-01-01T01:00:00+00:00');
56+
57+
Config::setClock(new FixedClock(new DateTimeImmutable('2026-01-01T02:00:00+00:00')));
58+
59+
$authenticator = new AccessTokenAuthenticator('access', 'refresh', $expiresAt);
60+
61+
expect($authenticator->hasExpired())->toBeTrue();
62+
expect($authenticator->hasNotExpired())->toBeFalse();
63+
});

0 commit comments

Comments
 (0)