Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/2-features/17-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}
}
35 changes: 30 additions & 5 deletions packages/auth/src/OAuth/GenericOAuthClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion packages/auth/src/OAuth/OAuthClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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;
}
48 changes: 46 additions & 2 deletions packages/auth/src/OAuth/Testing/TestingOAuthClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.
*/
Expand Down
121 changes: 121 additions & 0 deletions tests/Integration/Auth/OAuth/GenericOAuthClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}
Loading