Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/Authentication/OAuth2Authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
* - Proactive refresh when `expires_at < now + 60s`
* - Reactive refresh on `401` (handled by {@see Bc4Client::request()} via
* {@see refresh()})
* - Refresh-token rotation: every refresh yields a new refresh_token that
* replaces the previous one in storage
* - Refresh-token rotation when Launchpad sends one: a new refresh_token
* replaces the previous one in storage. The legacy `type=refresh`
* response usually carries only `access_token` (see the "legacy format
* refresh" case in basecamp/basecamp-sdk); the current refresh_token
* then stays valid and is kept
* - On `400 invalid_grant`: storage is marked `requires_reauth` and an
* {@see InvalidGrantException} is thrown
*
Expand All @@ -28,6 +31,12 @@ class OAuth2Authentication implements AuthenticationInterface
{
private const LAUNCHPAD_TOKEN_URL = 'https://launchpad.37signals.com/authorization/token';

/**
* Access-token lifetime documented by 37signals ("2 week lifetime,
* currently"), used when a refresh response omits `expires_in`.
*/
private const DEFAULT_EXPIRES_IN = 1209600;

private ?string $cachedAccessToken = null;
private ?string $cachedExpiresAt = null;
private ?HttpClientInterface $launchpadClient = null;
Expand Down Expand Up @@ -138,14 +147,15 @@ private function exchangeRefreshToken(string $refreshToken): array
throw new InvalidGrantException('Refresh token rejected by Launchpad (invalid_grant)');
}

if ($status >= 400 || !is_array($body) || !isset($body['access_token'], $body['refresh_token'], $body['expires_in'])) {
if ($status >= 400 || !is_array($body) || empty($body['access_token'])) {
throw new \RuntimeException(sprintf('BC4 token exchange failed (status=%d)', $status));
}

return [
'access_token' => (string) $body['access_token'],
'refresh_token' => (string) $body['refresh_token'],
'expires_in' => (int) $body['expires_in'],
// no rotation: the current refresh_token stays valid
'refresh_token' => !empty($body['refresh_token']) ? (string) $body['refresh_token'] : $refreshToken,
'expires_in' => isset($body['expires_in']) ? (int) $body['expires_in'] : self::DEFAULT_EXPIRES_IN,
];
}

Expand Down
60 changes: 60 additions & 0 deletions tests/Authentication/OAuth2AuthenticationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,66 @@ public function testProactiveRefreshOnExpiredAccessToken(): void
$this->assertSame(1, $launchpad->getRequestsCount(), 'Exactly one Launchpad call');
}

public function testRefreshWithoutRotationKeepsCurrentRefreshToken(): void
{
// Legacy `type=refresh` response: access_token only, no rotation and
// no expires_in (cf. "legacy format refresh" in basecamp/basecamp-sdk).
$launchpad = new MockHttpClient([
new MockResponse(json_encode(['access_token' => 'access-2']), ['http_code' => 200]),
]);

$exchanged = null;
$storage = $this->expiredTokenStorage(function (callable $exchange) use (&$exchanged) {
$exchanged = $exchange('refresh-1');

return [
'access_token' => $exchanged['access_token'],
'refresh_token' => $exchanged['refresh_token'],
'expires_at' => '2030-01-01T00:00:00+00:00',
];
});

$auth = new OAuth2Authentication('cid', 'secret', 'TestApp', 't@e.de', $storage, $launchpad);

$this->assertSame('access-2', $auth->getAccessToken());
$this->assertSame([
'access_token' => 'access-2',
'refresh_token' => 'refresh-1',
'expires_in' => 1209600,
], $exchanged);
}

public function testRefreshResponseWithoutAccessTokenFails(): void
{
$launchpad = new MockHttpClient([
new MockResponse(json_encode(['expires_in' => 1209600]), ['http_code' => 200]),
]);
$storage = $this->expiredTokenStorage(fn (callable $exchange) => $exchange('refresh-1'));

$auth = new OAuth2Authentication('cid', 'secret', 'TestApp', 't@e.de', $storage, $launchpad);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('BC4 token exchange failed (status=200)');
$auth->getAccessToken();
}

private function expiredTokenStorage(callable $refreshAndPersist): TokenStorageInterface
{
$storage = $this->createStub(TokenStorageInterface::class);
$storage->method('isFresh')->willReturnCallback(
static fn (string $iso) => (new \DateTimeImmutable($iso))->getTimestamp() > time() + 60,
);
$storage->method('loadTokens')->willReturn([
'access_token' => null,
'refresh_token' => 'refresh-1',
'expires_at' => '2020-01-01T00:00:00+00:00',
'requires_reauth' => false,
]);
$storage->method('refreshAndPersist')->willReturnCallback($refreshAndPersist);

return $storage;
}

public function testInvalidGrantMarksRequiresReauth(): void
{
$launchpad = new MockHttpClient([
Expand Down
Loading