Skip to content

Commit 873fae9

Browse files
brendtinnocenzi
andauthored
feat(auth): improve OAuth user flow (#1587)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 9ef1359 commit 873fae9

File tree

8 files changed

+252
-71
lines changed

8 files changed

+252
-71
lines changed

docs/2-features/17-oauth.md

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -52,35 +52,22 @@ final readonly class DiscordOAuthController
5252
#[Get('/auth/discord')]
5353
public function redirect(): Redirect
5454
{
55-
// Saves a unique token in the user's session
56-
$this->session->set('discord:oauth', $this->oauth->getState());
57-
58-
// Redirects to the OAuth provider's authorization page
59-
return new Redirect($this->oauth->getAuthorizationUrl());
55+
return $this->oauth->createRedirect(scopes: ['identify']);
6056
}
6157

6258
#[Get('/auth/discord/callback')]
6359
public function callback(Request $request): Redirect
6460
{
65-
// Validates the saved session token to prevent CSRF attacks
66-
if ($this->session->get('discord:oauth') !== $request->get('state')) {
67-
return new Redirect('/?error=invalid_state');
68-
}
69-
70-
// Fetches the user information from the OAuth provider
71-
$discordUser = $this->oauth->fetchUser($request->get('code'));
72-
73-
// Creates or updates the user in the database
74-
$user = query(User::class)->updateOrCreate([
75-
'discord_id' => $discordUser->id,
76-
], [
77-
'discord_id' => $discordUser->id,
78-
'username' => $discordUser->nickname,
79-
'email' => $discordUser->email,
80-
]);
81-
82-
// Finally, authenticates the user in the application
83-
$this->authenticator->authenticate($user);
61+
$user = $this->oauth->authenticate(
62+
request: $request,
63+
map: fn (OAuthUser $user): User => query(User::class)->updateOrCreate([
64+
'discord_id' => $user->id,
65+
], [
66+
'discord_id' => $user->id,
67+
'username' => $user->nickname,
68+
'email' => $user->email,
69+
])
70+
);
8471

8572
return new Redirect('/');
8673
}
@@ -245,7 +232,7 @@ Below is an example of a complete testing flow for an OAuth authentication:
245232
final class OAuthControllerTest extends IntegrationTestCase
246233
{
247234
#[Test]
248-
public function ensure_oauth(): void
235+
public function oauth(): void
249236
{
250237
// We create a fake OAuth client that will return
251238
// the specified user when the OAuth flow is completed
@@ -268,7 +255,7 @@ final class OAuthControllerTest extends IntegrationTestCase
268255
// We then simulate the callback from the provider
269256
// with a fake code and the expected state
270257
$this->http
271-
->get("/oauth/discord/callback?code=some-fake-code&state={$oauth->getState()}")
258+
->get("/oauth/discord/callback", query: ['code' => 'some-fake-code', 'state' => $oauth->getState()])
272259
->assertRedirect('/');
273260

274261
// We assert that an access token was retrieved
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Auth\Exceptions;
6+
7+
use Exception;
8+
9+
final class OAuthStateWasInvalid extends Exception implements AuthenticationException
10+
{
11+
}

packages/auth/src/OAuth/GenericOAuthClient.php

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,54 @@
44

55
namespace Tempest\Auth\OAuth;
66

7+
use BackedEnum;
8+
use Closure;
79
use League\OAuth2\Client\Provider\AbstractProvider;
810
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
911
use League\OAuth2\Client\Token\AccessToken;
12+
use Tempest\Auth\Authentication\Authenticatable;
13+
use Tempest\Auth\Authentication\Authenticator;
14+
use Tempest\Auth\Exceptions\OAuthStateWasInvalid;
1015
use Tempest\Auth\Exceptions\OAuthTokenCouldNotBeRetrieved;
1116
use Tempest\Auth\Exceptions\OAuthUserCouldNotBeRetrieved;
17+
use Tempest\Http\Request;
18+
use Tempest\Http\Responses\Redirect;
19+
use Tempest\Http\Session\Session;
1220
use Tempest\Mapper\ObjectFactory;
1321
use Tempest\Router\UriGenerator;
22+
use UnitEnum;
1423

15-
final readonly class GenericOAuthClient implements OAuthClient
24+
final class GenericOAuthClient implements OAuthClient
1625
{
1726
private AbstractProvider $provider;
1827

1928
public function __construct(
20-
private(set) OAuthConfig $config,
21-
private UriGenerator $uri,
22-
private ObjectFactory $factory,
29+
private(set) readonly OAuthConfig $config,
30+
private readonly UriGenerator $uri,
31+
private readonly ObjectFactory $factory,
32+
private readonly Session $session,
33+
private readonly Authenticator $authenticator,
2334
?AbstractProvider $provider = null,
2435
) {
2536
$this->provider = $provider ?? $this->config->createProvider();
2637
}
2738

28-
public function getAuthorizationUrl(array $scopes = [], array $options = []): string
39+
public string $sessionKey {
40+
get {
41+
$tag = $this->config->tag;
42+
43+
$key = match (true) {
44+
is_string($tag) => $tag,
45+
$tag instanceof BackedEnum => $tag->value,
46+
$tag instanceof UnitEnum => $tag->name,
47+
default => 'default',
48+
};
49+
50+
return "oauth:{$key}";
51+
}
52+
}
53+
54+
public function buildAuthorizationUrl(array $scopes = [], array $options = []): string
2955
{
3056
return $this->provider->getAuthorizationUrl([
3157
'scope' => $scopes ?? $this->config->scopes,
@@ -34,12 +60,19 @@ public function getAuthorizationUrl(array $scopes = [], array $options = []): st
3460
]);
3561
}
3662

63+
public function createRedirect(array $scopes = [], array $options = []): Redirect
64+
{
65+
$this->session->set($this->sessionKey, $this->provider->getState());
66+
67+
return new Redirect($this->buildAuthorizationUrl());
68+
}
69+
3770
public function getState(): ?string
3871
{
3972
return $this->provider->getState();
4073
}
4174

42-
public function getAccessToken(string $code): AccessToken
75+
public function requestAccessToken(string $code): AccessToken
4376
{
4477
try {
4578
return $this->provider->getAccessToken('authorization_code', [
@@ -51,7 +84,7 @@ public function getAccessToken(string $code): AccessToken
5184
}
5285
}
5386

54-
public function getUser(AccessToken $token): OAuthUser
87+
public function fetchUser(AccessToken $token): OAuthUser
5588
{
5689
try {
5790
return $this->config->mapUser(
@@ -63,10 +96,22 @@ public function getUser(AccessToken $token): OAuthUser
6396
}
6497
}
6598

66-
public function fetchUser(string $code): OAuthUser
99+
public function authenticate(Request $request, Closure $map): Authenticatable
67100
{
68-
return $this->getUser(
69-
token: $this->getAccessToken($code),
101+
if ($this->session->get($this->sessionKey) !== $request->get('state')) {
102+
throw new OAuthStateWasInvalid();
103+
}
104+
105+
$user = $this->fetchUser(
106+
token: $this->requestAccessToken(
107+
code: $request->get('code'),
108+
),
70109
);
110+
111+
$authenticable = $map($user);
112+
113+
$this->authenticator->authenticate($authenticable);
114+
115+
return $authenticable;
71116
}
72117
}

packages/auth/src/OAuth/OAuthClient.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,26 @@
44

55
namespace Tempest\Auth\OAuth;
66

7+
use Closure;
78
use League\OAuth2\Client\Token\AccessToken;
9+
use Tempest\Auth\Authentication\Authenticatable;
10+
use Tempest\Http\Request;
11+
use Tempest\Http\Responses\Redirect;
812

13+
/**
14+
* @template T of Authenticatable
15+
*/
916
interface OAuthClient
1017
{
1118
/**
1219
* Gets the authorization URL for the OAuth provider.
1320
*/
14-
public function getAuthorizationUrl(array $scopes = [], array $options = []): string;
21+
public function buildAuthorizationUrl(array $scopes = [], array $options = []): string;
22+
23+
/**
24+
* Creates a redirect response for the OAuth flow.
25+
*/
26+
public function createRedirect(array $scopes = [], array $options = []): Redirect;
1527

1628
/**
1729
* Gets the state parameter for CSRF protection.
@@ -21,15 +33,19 @@ public function getState(): ?string;
2133
/**
2234
* Exchanges an authorization code for an access token.
2335
*/
24-
public function getAccessToken(string $code): AccessToken;
36+
public function requestAccessToken(string $code): AccessToken;
2537

2638
/**
2739
* Gets user information from an OAuth provider using an access token.
2840
*/
29-
public function getUser(AccessToken $token): OAuthUser;
41+
public function fetchUser(AccessToken $token): OAuthUser;
3042

3143
/**
32-
* Completes OAuth flow with code and get user information.
44+
* Authenticates a user based on the given OAuth callback request.
45+
*
46+
* @template T of Authenticatable
47+
*
48+
* @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.
3349
*/
34-
public function fetchUser(string $code): OAuthUser;
50+
public function authenticate(Request $request, Closure $map): Authenticatable;
3551
}

packages/auth/src/OAuth/Testing/OAuthTester.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Auth\OAuth\Testing;
66

7+
use Tempest\Auth\Authentication\Authenticator;
78
use Tempest\Auth\OAuth\OAuthClient;
89
use Tempest\Auth\OAuth\OAuthConfig;
910
use Tempest\Auth\OAuth\OAuthUser;
@@ -24,10 +25,11 @@ public function fake(OAuthUser $user, null|string|UnitEnum $tag = null): Testing
2425
{
2526
$config = $this->container->get(OAuthConfig::class, $tag);
2627
$uri = $this->container->get(UriGenerator::class);
28+
$authenticator = $this->container->get(Authenticator::class);
2729

2830
$this->container->singleton(
2931
className: OAuthClient::class,
30-
definition: $client = new TestingOAuthClient($user, $config, $uri),
32+
definition: $client = new TestingOAuthClient($user, $config, $authenticator, $uri),
3133
tag: $tag,
3234
);
3335

packages/auth/src/OAuth/Testing/TestingOAuthClient.php

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@
44

55
namespace Tempest\Auth\OAuth\Testing;
66

7+
use Closure;
78
use League\OAuth2\Client\Token\AccessToken;
89
use PHPUnit\Framework\Assert;
10+
use Tempest\Auth\Authentication\Authenticatable;
11+
use Tempest\Auth\Authentication\Authenticator;
912
use Tempest\Auth\OAuth\OAuthClient;
1013
use Tempest\Auth\OAuth\OAuthConfig;
1114
use Tempest\Auth\OAuth\OAuthUser;
15+
use Tempest\Http\Request;
16+
use Tempest\Http\Responses\Redirect;
1217
use Tempest\Router\UriGenerator;
1318
use Tempest\Support\Arr;
1419
use Tempest\Support\Random;
@@ -38,19 +43,23 @@ final class TestingOAuthClient implements OAuthClient
3843
get => Arr\last($this->authorizationUrls)['url'] ?? null;
3944
}
4045

46+
/** @var array{url: string, scopes: array, options: array, state: string}[] */
4147
private array $authorizationUrls = [];
4248

49+
/** @var array{access_token: string, token_type: 'Bearer', expires_in: int, code: string}[] */
4350
private array $accessTokens = [];
4451

45-
private array $callbacks = [];
52+
/** @var array{token: AccessToken, code: string, user: OAuthUser}[] */
53+
private array $users = [];
4654

4755
public function __construct(
4856
private(set) OAuthUser $user,
49-
private(set) OAuthConfig $config,
50-
private UriGenerator $uri,
57+
private(set) readonly OAuthConfig $config,
58+
private readonly Authenticator $authenticator,
59+
private readonly UriGenerator $uri,
5160
) {}
5261

53-
public function getAuthorizationUrl(array $scopes = [], array $options = []): string
62+
public function buildAuthorizationUrl(array $scopes = [], array $options = []): string
5463
{
5564
$this->state = Random\secure_string(16);
5665

@@ -80,12 +89,13 @@ public function getState(): ?string
8089
return $this->state;
8190
}
8291

83-
public function getAccessToken(string $code): AccessToken
92+
public function requestAccessToken(string $code): AccessToken
8493
{
8594
$token = new AccessToken([
86-
'access_token' => 'fat-' . $code,
95+
'access_token' => 'tok-' . $code,
8796
'token_type' => 'Bearer',
8897
'expires_in' => 3600,
98+
'code' => $code,
8999
]);
90100

91101
$this->accessTokens[] = [
@@ -96,23 +106,31 @@ public function getAccessToken(string $code): AccessToken
96106
return $token;
97107
}
98108

99-
public function getUser(AccessToken $token): OAuthUser
109+
public function fetchUser(AccessToken $token): OAuthUser
100110
{
111+
$this->users[] = [
112+
'token' => $token,
113+
'code' => Arr\get_by_key($token->getValues(), 'code'),
114+
'user' => $this->user,
115+
];
116+
101117
return $this->user;
102118
}
103119

104-
public function fetchUser(string $code): OAuthUser
120+
public function createRedirect(array $scopes = [], array $options = []): Redirect
105121
{
106-
$token = $this->getAccessToken($code);
107-
$user = $this->getUser($token);
122+
return new Redirect($this->buildAuthorizationUrl($scopes, $options));
123+
}
108124

109-
$this->callbacks[] = [
110-
'code' => $code,
111-
'token' => $token,
112-
'user' => $user,
113-
];
125+
public function authenticate(Request $request, Closure $map): Authenticatable
126+
{
127+
$user = $this->fetchUser($this->requestAccessToken($request->get('code')));
128+
129+
$authenticatable = $map($user);
130+
131+
$this->authenticator->authenticate($authenticatable);
114132

115-
return $user;
133+
return $authenticatable;
116134
}
117135

118136
/**
@@ -189,8 +207,8 @@ public function assertAuthorizationUrlGenerated(?array $scopes = null, ?array $o
189207
public function assertUserFetched(string $code): void
190208
{
191209
Assert::assertNotEmpty(
192-
actual: array_filter($this->callbacks, fn (array $callback) => $callback['code'] === $code),
193-
message: sprintf('Callback with code "%s" was not handled.', $code),
210+
actual: array_filter($this->users, fn (array $user) => $user['code'] === $code),
211+
message: sprintf('User with code "%s" was not handled.', $code),
194212
);
195213
}
196214

@@ -238,6 +256,6 @@ public function getAccessTokenCount(): int
238256
*/
239257
public function getCallbackCount(): int
240258
{
241-
return count($this->callbacks);
259+
return count($this->users);
242260
}
243261
}

packages/http/src/Responses/Redirect.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ final class Redirect implements Response
1212
{
1313
use IsResponse;
1414

15-
public function __construct(string $to)
16-
{
15+
public function __construct(
16+
private(set) string $to,
17+
) {
1718
$this->status = Status::FOUND;
1819
$this->addHeader('Location', $to);
1920
}

0 commit comments

Comments
 (0)