From 2eff24e8106ffe50295c85ba11f1ecc38b1f5bc2 Mon Sep 17 00:00:00 2001 From: Boyd Bloemsma Date: Wed, 8 Jul 2026 19:33:43 +0300 Subject: [PATCH 1/2] feat(auth): support refreshing OAuth access tokens --- docs/2-features/17-oauth.md | 29 +++++ .../OAuthTokenCouldNotBeRetrieved.php | 7 + .../auth/src/OAuth/GenericOAuthClient.php | 35 ++++- packages/auth/src/OAuth/OAuthClient.php | 7 +- .../src/OAuth/Testing/TestingOAuthClient.php | 48 ++++++- .../Auth/OAuth/GenericOAuthClientTest.php | 121 ++++++++++++++++++ .../Auth/OAuth/TestingOAuthClientTest.php | 119 +++++++++++++++++ 7 files changed, 358 insertions(+), 8 deletions(-) diff --git a/docs/2-features/17-oauth.md b/docs/2-features/17-oauth.md index 5a0a2ad04b..1de716fe6f 100644 --- a/docs/2-features/17-oauth.md +++ b/docs/2-features/17-oauth.md @@ -90,6 +90,8 @@ final readonly class DiscordOAuthController Of course, this example assumes that the database and an [authenticatable model](../2-features/04-authentication.md#authentication) are configured. +The `map` callback may also accept the access token as its second argument, which is useful for [persisting tokens](#refreshing-an-access-token). + ### Working with the OAuth user When an OAuth flow is completed and you call `fetchUser`, you will receive an {b`Tempest\Auth\OAuth\OAuthUser`} object containing the user's information from the OAuth provider: @@ -108,6 +110,33 @@ $user->raw; // Raw user data from the OAuth provider As seen in the example above, you can use this information to create or update a user in your database, or to authenticate them directly. +### Refreshing an access token + +Access tokens are short-lived. If the provider issued a refresh token, you can exchange it for a new access token using the `refreshAccessToken` method: + +```php +$token = $this->oauth->refreshAccessToken($refreshToken); + +$token->getToken(); // The new access token +$token->getRefreshToken(); // A new refresh token, if the provider rotates them +$token->getExpires(); // The new expiration timestamp +``` + +Note that Tempest does not store access or refresh tokens, so persisting them is your application's responsibility. To do so, you may use the second parameter of the `map` callback, which receives the {b`League\OAuth2\Client\Token\AccessToken`} obtained during authentication: + +```php +$user = $this->oauth->authenticate( + request: $request, + map: fn (OAuthUser $user, AccessToken $token): User => query(User::class)->updateOrCreate([ + 'github_id' => $user->id, + ], [ + 'github_id' => $user->id, + 'github_access_token' => $token->getToken(), + 'github_refresh_token' => $token->getRefreshToken(), + ]), +); +``` + ## Configuring a provider Most providers require only a `clientId`, `clientSecret` and `redirectTo`, but some might need other parameters. A typical configuration looks like the following: diff --git a/packages/auth/src/Exceptions/OAuthTokenCouldNotBeRetrieved.php b/packages/auth/src/Exceptions/OAuthTokenCouldNotBeRetrieved.php index b54adafee9..39b21c1ca1 100644 --- a/packages/auth/src/Exceptions/OAuthTokenCouldNotBeRetrieved.php +++ b/packages/auth/src/Exceptions/OAuthTokenCouldNotBeRetrieved.php @@ -16,4 +16,11 @@ public static function fromProviderException(?Throwable $previous = null): self previous: $previous, ); } + + public static function missingRefreshToken(): self + { + return new self( + message: 'No refresh token was provided.', + ); + } } diff --git a/packages/auth/src/OAuth/GenericOAuthClient.php b/packages/auth/src/OAuth/GenericOAuthClient.php index 2f1bd8db42..3660e9765b 100644 --- a/packages/auth/src/OAuth/GenericOAuthClient.php +++ b/packages/auth/src/OAuth/GenericOAuthClient.php @@ -101,6 +101,32 @@ public function requestAccessToken(string $code): AccessToken } } + public function refreshAccessToken(string $refreshToken): AccessToken + { + if ($refreshToken === '') { + throw OAuthTokenCouldNotBeRetrieved::missingRefreshToken(); + } + + try { + $token = $this->provider->getAccessToken('refresh_token', [ + 'refresh_token' => $refreshToken, + ]); + + if ($token instanceof AccessToken) { + return $token; + } + + return new AccessToken([ + ...$token->getValues(), + 'access_token' => $token->getToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires' => $token->getExpires(), + ]); + } catch (IdentityProviderException $exception) { + throw OAuthTokenCouldNotBeRetrieved::fromProviderException($exception); + } + } + public function fetchUser(AccessToken $token): OAuthUser { try { @@ -124,13 +150,12 @@ public function authenticate(Request $request, Closure $map): Authenticatable throw new OAuthStateWasInvalid(); } - $user = $this->fetchUser( - token: $this->requestAccessToken( - code: $request->get('code'), - ), + $token = $this->requestAccessToken( + code: $request->get('code'), ); + $user = $this->fetchUser(token: $token); - $authenticable = $map($user); + $authenticable = $map($user, $token); $this->authenticator->authenticate($authenticable); diff --git a/packages/auth/src/OAuth/OAuthClient.php b/packages/auth/src/OAuth/OAuthClient.php index 63eb5566fe..94c7d0523e 100644 --- a/packages/auth/src/OAuth/OAuthClient.php +++ b/packages/auth/src/OAuth/OAuthClient.php @@ -35,6 +35,11 @@ public function getState(): ?string; */ public function requestAccessToken(string $code): AccessToken; + /** + * Exchanges a refresh token for a new access token. + */ + public function refreshAccessToken(string $refreshToken): AccessToken; + /** * Gets user information from an OAuth provider using an access token. */ @@ -43,7 +48,7 @@ public function fetchUser(AccessToken $token): OAuthUser; /** * Authenticates a user based on the given OAuth callback request. * - * @param Closure(OAuthUser): T $map A callback that should return an authenticatable model from the given OAuthUser. Typically, the callback is also responsible for saving the user to the database. + * @param Closure(OAuthUser, AccessToken): T $map A callback that should return an authenticatable model from the given OAuthUser. Typically, the callback is also responsible for saving the user to the database. */ public function authenticate(Request $request, Closure $map): Authenticatable; } diff --git a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php index f38a654056..5a791ea05e 100644 --- a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php +++ b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php @@ -10,6 +10,7 @@ use Tempest\Auth\Authentication\Authenticatable; use Tempest\Auth\Authentication\Authenticator; use Tempest\Auth\Exceptions\OAuthStateWasInvalid; +use Tempest\Auth\Exceptions\OAuthTokenCouldNotBeRetrieved; use Tempest\Auth\OAuth\OAuthClient; use Tempest\Auth\OAuth\OAuthConfig; use Tempest\Auth\OAuth\OAuthUser; @@ -50,6 +51,9 @@ final class TestingOAuthClient implements OAuthClient /** @var array{access_token: string, token_type: 'Bearer', expires_in: int, code: string}[] */ private array $accessTokens = []; + /** @var array{refresh_token: string, token: AccessToken}[] */ + private array $refreshedTokens = []; + /** @var array{token: AccessToken, code: string, user: OAuthUser}[] */ private array $users = []; @@ -107,6 +111,27 @@ public function requestAccessToken(string $code): AccessToken return $token; } + public function refreshAccessToken(string $refreshToken): AccessToken + { + if ($refreshToken === '') { + throw OAuthTokenCouldNotBeRetrieved::missingRefreshToken(); + } + + $token = new AccessToken([ + 'access_token' => 'tok-refreshed-' . $refreshToken, + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token' => 'refresh-' . $refreshToken, + ]); + + $this->refreshedTokens[] = [ + 'refresh_token' => $refreshToken, + 'token' => $token, + ]; + + return $token; + } + public function fetchUser(AccessToken $token): OAuthUser { $this->users[] = [ @@ -134,9 +159,12 @@ public function authenticate(Request $request, Closure $map): Authenticatable throw new OAuthStateWasInvalid(); } - $user = $this->fetchUser($this->requestAccessToken($request->get('code'))); + $token = $this->requestAccessToken( + code: $request->get('code'), + ); + $user = $this->fetchUser($token); - $authenticatable = $map($user); + $authenticatable = $map($user, $token); $this->authenticator->authenticate($authenticatable); @@ -237,6 +265,22 @@ public function assertAccessTokenRetrieved(?string $code = null): void } } + /** + * Asserts that an access token was refreshed with the specified refresh token. + */ + public function assertAccessTokenRefreshed(?string $refreshToken = null): void + { + Assert::assertNotEmpty($this->refreshedTokens, 'No tokens were refreshed.'); + + if ($refreshToken !== null) { + // @mago-expect lint:no-insecure-comparison + Assert::assertNotEmpty( + actual: array_filter($this->refreshedTokens, fn (array $token) => $token['refresh_token'] === $refreshToken), + message: sprintf('No access token was refreshed for refresh token "%s".', $refreshToken), + ); + } + } + /** * Asserts that the OAuth state matches the expected value. */ diff --git a/tests/Integration/Auth/OAuth/GenericOAuthClientTest.php b/tests/Integration/Auth/OAuth/GenericOAuthClientTest.php index f5fc9cf162..44001c025e 100644 --- a/tests/Integration/Auth/OAuth/GenericOAuthClientTest.php +++ b/tests/Integration/Auth/OAuth/GenericOAuthClientTest.php @@ -3,13 +3,16 @@ namespace Tests\Tempest\Integration\Auth\OAuth; use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Provider\ResourceOwnerInterface; use League\OAuth2\Client\Token\AccessToken; use LogicException; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\Test; use Psr\Http\Message\ResponseInterface; use ReflectionClass; use Tempest\Auth\Exceptions\OAuthStateWasInvalid; +use Tempest\Auth\Exceptions\OAuthTokenCouldNotBeRetrieved; use Tempest\Auth\Exceptions\OAuthWasNotConfigured; use Tempest\Auth\OAuth\Config\GitHubOAuthConfig; use Tempest\Auth\OAuth\GenericOAuthClient; @@ -121,4 +124,122 @@ protected function createResourceOwner(array $response, AccessToken $token): Res map: static fn () => throw new LogicException('User should not be mapped when state is missing.'), ); } + + #[Test] + public function can_refresh_access_token(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'client-id', + clientSecret: 'client-secret', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/callback', + scopes: ['user:email'], + )); + + /** @var GenericOAuthClient $oauth */ + $oauth = $this->container->get(OAuthClient::class); + + $reflection = new ReflectionClass($oauth); + $reflection->getProperty('provider')->setValue($oauth, new class extends AbstractProvider { + public function getBaseAuthorizationUrl(): string + { + return 'https://provider.test/authorize'; + } + + public function getBaseAccessTokenUrl(array $params): string + { + return 'https://provider.test/token'; + } + + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + return 'https://provider.test/user'; + } + + public function getAccessToken($grant, array $options = []) + { + Assert::assertSame('refresh_token', (string) $grant); + Assert::assertSame('my-refresh-token', $options['refresh_token']); + + return new AccessToken([ + 'access_token' => 'new-access-token', // @mago-expect lint:no-literal-password + 'refresh_token' => 'new-refresh-token', // @mago-expect lint:no-literal-password + 'expires_in' => 3600, + ]); + } + + protected function getDefaultScopes(): array + { + return []; + } + + protected function checkResponse(ResponseInterface $response, $data): void {} + + protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface + { + throw new LogicException('Resource owner should not be created when refreshing a token.'); + } + }); + + $token = $oauth->refreshAccessToken('my-refresh-token'); + + $this->assertSame('new-access-token', $token->getToken()); + $this->assertSame('new-refresh-token', $token->getRefreshToken()); + } + + #[Test] + public function provider_exception_is_wrapped_when_refreshing(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'client-id', + clientSecret: 'client-secret', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/callback', + scopes: ['user:email'], + )); + + /** @var GenericOAuthClient $oauth */ + $oauth = $this->container->get(OAuthClient::class); + + $reflection = new ReflectionClass($oauth); + $reflection->getProperty('provider')->setValue($oauth, new class extends AbstractProvider { + public function getBaseAuthorizationUrl(): string + { + return 'https://provider.test/authorize'; + } + + public function getBaseAccessTokenUrl(array $params): string + { + return 'https://provider.test/token'; + } + + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + return 'https://provider.test/user'; + } + + public function getAccessToken($grant, array $options = []) + { + throw new IdentityProviderException( + message: 'The refresh token is invalid.', + code: 400, + response: [], + ); + } + + protected function getDefaultScopes(): array + { + return []; + } + + protected function checkResponse(ResponseInterface $response, $data): void {} + + protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface + { + throw new LogicException('Resource owner should not be created when refreshing fails.'); + } + }); + + $this->expectException(OAuthTokenCouldNotBeRetrieved::class); + + $oauth->refreshAccessToken('my-refresh-token'); + } } diff --git a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php index c11867d825..1f4327f6e2 100644 --- a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php +++ b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php @@ -2,10 +2,13 @@ namespace Tests\Tempest\Integration\Auth\OAuth; +use League\OAuth2\Client\Token\AccessToken; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use Tempest\Auth\Authentication\Authenticatable; use Tempest\Auth\Exceptions\OAuthStateWasInvalid; +use Tempest\Auth\Exceptions\OAuthTokenCouldNotBeRetrieved; use Tempest\Auth\OAuth\Config\GitHubOAuthConfig; use Tempest\Auth\OAuth\OAuthClient; use Tempest\Auth\OAuth\OAuthUser; @@ -355,6 +358,122 @@ public function missing_state_is_rejected(): void ), ); } + + #[Test] + public function map_callback_receives_the_access_token(): void + { + $this->database->reset(migrate: false); + $this->database->migrate(CreateMigrationsTable::class, CreateUsersTable::class); + + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $client->createRedirect(); + + $receivedToken = null; + + $client->authenticate( + request: new GenericRequest( + method: Method::GET, + uri: Uri\set_query('/oauth/callback', code: 'auth-code', state: $client->getState()), + ), + map: static function (OAuthUser $user, AccessToken $token) use (&$receivedToken): User { + $receivedToken = $token; + + return query(User::class)->updateOrCreate( + [ + 'github_id' => $user->id, + ], + [ + 'email' => $user->email ?? '', + 'full_name' => $user->name ?? '', + 'username' => $user->nickname ?? '', + ] + ); + } + ); + + $this->assertInstanceOf(AccessToken::class, $receivedToken); + $this->assertSame('tok-auth-code', $receivedToken->getToken()); + } + + #[Test] + public function can_refresh_access_token(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $token = $client->refreshAccessToken('my-refresh-token'); + + $this->assertEquals('tok-refreshed-my-refresh-token', $token->getToken()); + $this->assertEquals('refresh-my-refresh-token', $token->getRefreshToken()); + $this->assertNotNull($token->getExpires()); + + $client->assertAccessTokenRefreshed('my-refresh-token'); + } + + #[Test] + public function refreshed_token_is_recorded(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $client->refreshAccessToken('my-refresh-token'); + + $client->assertAccessTokenRefreshed(); + $client->assertAccessTokenRefreshed('my-refresh-token'); + + $this->expectException(AssertionFailedError::class); + + $client->assertAccessTokenRefreshed('some-other-token'); + } + + #[Test] + public function refreshing_without_refresh_token_throws(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $this->expectException(OAuthTokenCouldNotBeRetrieved::class); + + $client->refreshAccessToken(''); + } + + #[Test] + public function assert_access_token_refreshed_fails_when_nothing_refreshed(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $this->expectException(AssertionFailedError::class); + + $client->assertAccessTokenRefreshed(); + } } final class User implements Authenticatable From da4149c2f995dd1892e7e2b91e534487af2f2100 Mon Sep 17 00:00:00 2001 From: Boyd Bloemsma Date: Wed, 8 Jul 2026 23:30:06 +0300 Subject: [PATCH 2/2] chore: run mago --- tests/Integration/Auth/OAuth/TestingOAuthClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php index 1f4327f6e2..ae9217195e 100644 --- a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php +++ b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php @@ -393,9 +393,9 @@ public function map_callback_receives_the_access_token(): void 'email' => $user->email ?? '', 'full_name' => $user->name ?? '', 'username' => $user->nickname ?? '', - ] + ], ); - } + }, ); $this->assertInstanceOf(AccessToken::class, $receivedToken);