Skip to content

Commit 742d4fb

Browse files
authored
feat(auth): add support for OAuth (#1577)
1 parent a6145d0 commit 742d4fb

30 files changed

+2182
-2
lines changed

composer.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"league/commonmark": "^2.7",
2121
"league/flysystem": "^3.29.1",
2222
"league/mime-type-detection": "^1.16",
23+
"league/oauth2-client": "^2.8",
2324
"monolog/monolog": "^3.7.0",
2425
"nette/php-generator": "^4.1.6",
2526
"nikic/php-parser": "^5.3",
@@ -45,9 +46,11 @@
4546
"voku/portable-ascii": "^2.0.3"
4647
},
4748
"require-dev": {
49+
"adam-paterson/oauth2-slack": "^1.1",
4850
"aws/aws-sdk-php": "^3.338.0",
4951
"azure-oss/storage-blob-flysystem": "^1.2",
5052
"carthage-software/mago": "1.0.0-beta.24",
53+
"depotwarehouse/oauth2-twitch": "^1.3",
5154
"guzzlehttp/psr7": "^2.6.1",
5255
"league/flysystem-aws-s3-v3": "^3.25.1",
5356
"league/flysystem-ftp": "^3.25.1",
@@ -57,23 +60,34 @@
5760
"league/flysystem-read-only": "^3.25.1",
5861
"league/flysystem-sftp-v3": "^3.25.1",
5962
"league/flysystem-ziparchive": "^3.25.1",
63+
"league/oauth2-facebook": "^2.0",
64+
"league/oauth2-github": "^3.1",
65+
"league/oauth2-google": "^4.0",
66+
"league/oauth2-instagram": "^3.0",
67+
"league/oauth2-linkedin": "^5.1",
6068
"masterminds/html5": "^2.9",
6169
"microsoft/azure-storage-blob": "^1.5",
6270
"mikey179/vfsstream": "^2.0@dev",
6371
"nesbot/carbon": "^3.8",
6472
"nyholm/psr7": "^1.8",
73+
"patrickbussmann/oauth2-apple": "^0.3",
6574
"phpat/phpat": "^0.11.0",
6675
"phpbench/phpbench": "84.x-dev",
6776
"phpstan/phpstan": "^2.0",
6877
"phpunit/phpunit": "^12.2.3",
6978
"predis/predis": "^3.0.0",
79+
"riskio/oauth2-auth0": "^2.4",
80+
"smolblog/oauth2-twitter": "^1.0",
7081
"spatie/phpunit-snapshot-assertions": "^5.1.8",
7182
"spaze/phpstan-disallowed-calls": "^4.0",
83+
"stevenmaguire/oauth2-microsoft": "^2.2",
7284
"symfony/amazon-mailer": "^7.2.0",
7385
"symfony/postmark-mailer": "^7.2.6",
7486
"symplify/monorepo-builder": "^11.2",
7587
"tempest/blade": "dev-main",
76-
"twig/twig": "^3.16"
88+
"thenetworg/oauth2-azure": "^2.2",
89+
"twig/twig": "^3.16",
90+
"wohali/oauth2-discord-new": "^1.2"
7791
},
7892
"replace": {
7993
"tempest/auth": "self.version",
@@ -186,6 +200,7 @@
186200
},
187201
"autoload-dev": {
188202
"psr-4": {
203+
"Tempest\\Auth\\Tests\\": "packages/auth/tests",
189204
"Tempest\\Cache\\Tests\\": "packages/cache/tests",
190205
"Tempest\\Clock\\Tests\\": "packages/clock/tests",
191206
"Tempest\\CommandBus\\Tests\\": "packages/command-bus/tests",

docs/2-features/17-oauth.md

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
---
2+
title: OAuth
3+
description: "Learn how to implement OAuth to authenticate users with many different providers, such as GitHub, Google, Discord, and many others."
4+
keywords: "Experimental"
5+
---
6+
7+
## Overview
8+
9+
Tempest provides the ability to authenticate users with many OAuth providers, such as GitHub, Google, Discord, and many others, using the same interface.
10+
11+
This implementation is built on top of the PHP league's [OAuth client](https://github.com/thephpleague/oauth2-client)—a reliable, battle-tested OAuth 2.0 client library.
12+
13+
## Getting started
14+
15+
To get started with OAuth, you will first need to create a configuration file for your desired OAuth provider.
16+
17+
Tempest provides a [different configuration object for each provider](#available-providers). For instance, if you wish to authenticate users with GitHub, you may create a `github.config.php` file returning an instance of {b`Tempest\Auth\OAuth\Config\GitHubOAuthConfig`}:
18+
19+
```php app/Auth/github.config.php
20+
return new GitHubOAuthConfig(
21+
clientId: env('GITHUB_CLIENT_ID'),
22+
clientSecret: env('GITHUB_CLIENT_SECRET'),
23+
redirectTo: [GitHubOAuthController::class, 'callback'],
24+
scopes: ['user:email'],
25+
);
26+
```
27+
28+
In this example, the GitHub OAuth credentials are specified in the `.env`, so different credentials can be configured depending on the environment.
29+
30+
Once your OAuth provider is configured, you may interact with it by using the {`Tempest\Auth\OAuth\OAuthClient`} interface. This is usually done through [dependency injection](../1-essentials/05-container.md#injecting-dependencies).
31+
32+
## Implementing the OAuth flow
33+
34+
To implement a complete OAuth flow for your application, you will need two routes.
35+
36+
- The first one will redirect the user to the OAuth provider's authorization page,
37+
- The second one, which will be redirected to once the user authorizes your application, will fetch the user's information thanks to the code provided by the OAuth provider.
38+
39+
The {b`Tempest\Auth\OAuth\OAuthClient`} interface has the necessary methods to handle both parts of the flow. The following is an example of a complete OAuth flow, including CSRF protection, creating or updating the user, and authenticating them against the application:
40+
41+
```php app/Auth/DiscordOAuthController.php
42+
use Tempest\Auth\OAuth\OAuthClient;
43+
44+
final readonly class DiscordOAuthController
45+
{
46+
public function __construct(
47+
private OAuthClient $oauth,
48+
private Session $session,
49+
private Authenticator $authenticator,
50+
) {}
51+
52+
#[Get('/auth/discord')]
53+
public function redirect(): Redirect
54+
{
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());
60+
}
61+
62+
#[Get('/auth/discord/callback')]
63+
public function callback(Request $request): Redirect
64+
{
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);
84+
85+
return new Redirect('/');
86+
}
87+
}
88+
```
89+
90+
Of course, this example assumes that the database and an [authenticatable model](../2-features/04-authentication.md#authentication) are configured.
91+
92+
### Working with the OAuth user
93+
94+
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:
95+
96+
```php
97+
$user = $this->oauth->fetchUser($code);
98+
99+
$user->id; // The unique identifier for the user from the OAuth provider
100+
$user->email; // The user's email address
101+
$user->name; // The user's name
102+
$user->nickname; // The user's nickname/username
103+
$user->avatar; // The user's avatar URL
104+
$user->provider; // The OAuth provider name
105+
$user->raw; // Raw user data from the OAuth provider
106+
```
107+
108+
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.
109+
110+
## Configuring a provider
111+
112+
Most providers require only a `clientId`, `clientSecret` and `redirectTo`, but some might need other parameters. A typical configuration looks like the following:
113+
114+
```php app/Auth/github.config.php
115+
return new GitHubOAuthConfig(
116+
clientId: env('GITHUB_CLIENT_ID'),
117+
clientSecret: env('GITHUB_CLIENT_SECRET'),
118+
redirectTo: [GitHubOAuthController::class, 'callback'],
119+
scopes: ['user:email'],
120+
);
121+
```
122+
123+
Note that the `redirectTo` accepts a tuple of a controller class and a method name, which will be resolved to the full URL of the route handled by that method. You may also provide an URI path if you prefer.
124+
125+
### Supporting multiple providers
126+
127+
If you need to work with multiple OAuth providers, you may create multiple OAuth configurations using tags. These tags may then be used to resolve the {b`Tempest\Auth\OAuth\OAuthClient`} interface, which will use the corresponding configuration.
128+
129+
It's a good practice to use an enum for the tag:
130+
131+
```php app/Auth/Provider.php
132+
enum Provider
133+
{
134+
case GITHUB;
135+
case GOOGLE;
136+
case DISCORD;
137+
}
138+
```
139+
140+
```php app/Auth/github.config.php
141+
return new GitHubOAuthConfig(
142+
tag: Provider::GITHUB,
143+
clientId: env('GITHUB_CLIENT_ID'),
144+
clientSecret: env('GITHUB_CLIENT_SECRET'),
145+
redirectTo: [OAuthController::class, 'handleGitHubCallback'],
146+
scopes: ['user:email'],
147+
);
148+
```
149+
150+
```php app/Auth/google.config.php
151+
return new GoogleOAuthConfig(
152+
tag: Provider::GOOGLE,
153+
clientId: env('GOOGLE_CLIENT_ID'),
154+
clientSecret: env('GOOGLE_CLIENT_SECRET'),
155+
redirectTo: [GoogleOAuthController::class, 'handleGoogleCallback'],
156+
);
157+
```
158+
159+
Once you have configured your OAuth providers and your tags, you may inject the {b`Tempest\Auth\OAuth\OAuthClient`} interface using the corresponding tag:
160+
161+
```php app/AuthController.php
162+
use Tempest\Container\Tag;
163+
164+
final readonly class AuthController
165+
{
166+
public function __construct(
167+
#[Tag(OAuthProvider::GITHUB)]
168+
private OAuthClient $githubClient,
169+
#[Tag(OAuthProvider::GOOGLE)]
170+
private OAuthClient $googleClient,
171+
) {}
172+
173+
#[Get('/auth/github')]
174+
public function redirectToGitHub(): Redirect
175+
{
176+
// ...
177+
178+
return new Redirect($this->githubClient->getAuthorizationUrl());
179+
}
180+
181+
#[Get('/auth/github/callback')]
182+
public function handleGitHubCallback(Request $request): Redirect
183+
{
184+
$githubUser = $this->githubClient->handleCallback($request->get('code'));
185+
186+
// ...
187+
}
188+
189+
// Do the same for Google
190+
}
191+
```
192+
193+
### Using a generic provider
194+
195+
If you need to implement OAuth with a provider that Tempest doesn't have a specific configuration for, you may use the {b`Tempest\Auth\OAuth\Config\GenericOAuthConfig`}:
196+
197+
```php app/Auth/custom.config.php
198+
return new GenericOAuthConfig(
199+
clientId: env('CUSTOM_CLIENT_ID'),
200+
clientSecret: env('CUSTOM_CLIENT_SECRET'),
201+
redirectTo: [OAuthController::class, 'handleCallback'],
202+
urlAuthorize: 'https://provider.com/oauth/authorize',
203+
urlAccessToken: 'https://provider.com/oauth/token',
204+
urlResourceOwnerDetails: 'https://provider.com/api/user',
205+
scopes: ['read:user'],
206+
);
207+
```
208+
209+
### Available providers
210+
211+
Tempest provides a different configuration object for each OAuth provider. Below are the ones that are currently supported:
212+
213+
- **GitHub** authentication using {b`Tempest\Auth\OAuth\Config\GitHubOAuthConfig`},
214+
- **Google** authentication using {b`Tempest\Auth\OAuth\Config\GoogleOAuthConfig`},
215+
- **Facebook** authentication using {b`Tempest\Auth\OAuth\Config\FacebookOAuthConfig`},
216+
- **Discord** authentication using {b`Tempest\Auth\OAuth\Config\DiscordOAuthConfig`},
217+
- **Instagram** authentication using {b`Tempest\Auth\OAuth\Config\InstagramOAuthConfig`},
218+
- **LinkedIn** authentication using {b`Tempest\Auth\OAuth\Config\LinkedInOAuthConfig`},
219+
- **Microsoft** authentication using {b`Tempest\Auth\OAuth\Config\MicrosoftOAuthConfig`},
220+
- **Slack** authentication using {b`Tempest\Auth\OAuth\Config\SlackOAuthConfig`},
221+
- **Apple** authentication using {b`Tempest\Auth\OAuth\Config\AppleOAuthConfig`},
222+
- Any other OAuth platform using {b`Tempest\Auth\OAuth\Config\GenericOAuthConfig`}.
223+
224+
## Testing
225+
226+
By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the OAuth testing utilities through the `oauth` property.
227+
228+
These utilities include a way to replace the OAuth client with a testing implementation, as well as a few assertion methods related to OAuth flows.
229+
230+
### Faking an OAuth client
231+
232+
You may generate a fake, testing-only OAuth client by calling the `fake()` method on the `oauth` property. This will replace the OAuth client implementation in the container, and provide useful assertion methods.
233+
234+
```php tests/AuthControllerTest.php
235+
$oauth = $this->oauth->fake(new OAuthUser(
236+
id: 'jon',
237+
238+
nickname: 'jondoe',
239+
));
240+
```
241+
242+
Below is an example of a complete testing flow for an OAuth authentication:
243+
244+
```php tests/AuthControllerTest.php
245+
final class OAuthControllerTest extends IntegrationTestCase
246+
{
247+
#[Test]
248+
public function ensure_oauth(): void
249+
{
250+
// We create a fake OAuth client that will return
251+
// the specified user when the OAuth flow is completed
252+
$oauth = $this->oauth->fake(new OAuthUser(
253+
id: 'jon',
254+
255+
nickname: 'jondoe',
256+
));
257+
258+
// We first simulate a call to the endpoint
259+
// that redirects to the provider
260+
$this->http
261+
->get('/oauth/discord')
262+
->assertRedirect($oauth->lastAuthorizationUrl);
263+
264+
// We check that the authorization URL was generated,
265+
// optionally specifying scopes and options
266+
$oauth->assertAuthorizationUrlGenerated();
267+
268+
// We then simulate the callback from the provider
269+
// with a fake code and the expected state
270+
$this->http
271+
->get("/oauth/discord/callback?code=some-fake-code&state={$oauth->getState()}")
272+
->assertRedirect('/');
273+
274+
// We assert that an access token was retrieved
275+
// with the same fake code we provided before
276+
$oauth->assertUserFetched(code: 'some-fake-code');
277+
278+
// Finally, we ensure a user was created with the
279+
// credentials we specified in the fake OAuth user
280+
$user = query(User::class)
281+
->find(discord_id: 'jon')
282+
->first();
283+
284+
$this->assertInstanceOf(User::class, $user);
285+
$this->assertSame('[email protected]', $user->email);
286+
$this->assertSame('jondoe', $user->username);
287+
}
288+
}
289+
```

packages/auth/composer.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,35 @@
55
"php": "^8.4",
66
"tempest/core": "dev-main",
77
"tempest/router": "dev-main",
8-
"tempest/database": "dev-main"
8+
"tempest/database": "dev-main",
9+
"tempest/mapper": "dev-main",
10+
"league/oauth2-client": "^2.8"
11+
},
12+
"require-dev": {
13+
"league/oauth2-github": "^3.1",
14+
"league/oauth2-google": "^4.0",
15+
"league/oauth2-facebook": "^2.0",
16+
"league/oauth2-instagram": "^3.0",
17+
"league/oauth2-linkedin": "^5.1",
18+
"patrickbussmann/oauth2-apple": "^0.3",
19+
"stevenmaguire/oauth2-microsoft": "^2.2",
20+
"thenetworg/oauth2-azure": "^2.2",
21+
"riskio/oauth2-auth0": "^2.4",
22+
"adam-paterson/oauth2-slack": "^1.1",
23+
"wohali/oauth2-discord-new": "^1.2",
24+
"smolblog/oauth2-twitter": "^1.0",
25+
"depotwarehouse/oauth2-twitch": "^1.3"
926
},
1027
"autoload": {
1128
"psr-4": {
1229
"Tempest\\Auth\\": "src"
1330
}
1431
},
32+
"autoload-dev": {
33+
"psr-4": {
34+
"Tempest\\Auth\\Tests\\": "tests"
35+
}
36+
},
1537
"license": "MIT",
1638
"minimum-stability": "dev"
1739
}

0 commit comments

Comments
 (0)