Skip to content

Commit 8dd35db

Browse files
committed
feat(auth): add support for OAuth
1 parent a0acdbd commit 8dd35db

29 files changed

+2077
-3
lines changed

composer.json

Lines changed: 17 additions & 2 deletions
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",
50-
"carthage-software/mago": "^1.0.0-beta.16",
52+
"carthage-software/mago": "1.0.0-beta.16",
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: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
---
2+
title: OAuth
3+
description: "Tempest's OAuth provides a way to authenticate users with many different OAuth 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 [OAuth 2.0 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. 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+
redirectUri: [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 to use the {b`Tempest\Auth\OAuth\OAuthClient`} interface to redirect the user to the OAuth provider's authorization page, and fetch the user's information in the controller action to which the OAuth provider redirects back.
35+
36+
The following is an example of a full OAuth flow, including CSRF protection, saving or updating the user, and authenticating them against the application:
37+
38+
```php app/Auth/GitHubOAuthController.php
39+
use Tempest\Auth\OAuth\OAuthClient;
40+
41+
final readonly class GitHubOAuthController
42+
{
43+
public function __construct(
44+
private OAuthClient $oauth,
45+
private Session $session,
46+
private Authenticator $authenticator,
47+
) {}
48+
49+
#[Get('/auth/github')]
50+
public function redirect(): Redirect
51+
{
52+
$this->session->set('github:oauth', $this->oauth->getState());
53+
54+
return new Redirect($this->oauth->getAuthorizationUrl());
55+
}
56+
57+
#[Get('/auth/github/callback')]
58+
public function callback(Request $request): Redirect
59+
{
60+
if ($this->session->get('github:oauth') !== $request->get('state')) {
61+
return new Redirect('/?error=invalid_state');
62+
}
63+
64+
$githubUser = $this->oauth->fetchUser($request->get('code'));
65+
66+
$user = query(User::class)->updateOrCreate([
67+
'discord_id' => $githubUser->id,
68+
], [
69+
'discord_id' => $githubUser->id,
70+
'username' => $githubUser->nickname,
71+
'email' => $githubUser->email,
72+
]);
73+
74+
$this->authenticator->authenticate($user);
75+
76+
return new Redirect('/');
77+
}
78+
}
79+
```
80+
81+
Of course, this example assumes that an [authenticatable model](../2-features/04-authentication.md#authentication) is configured.
82+
83+
### Working with the OAuth user
84+
85+
When an OAuth flow is completed, you will receive an {b`Tempest\Auth\OAuth\OAuthUser`} object containing the user's information from the OAuth provider:
86+
87+
```php
88+
$user = $this->oauth->fetchUser($code);
89+
90+
$user->id; // The unique identifier for the user from the OAuth provider
91+
$user->email; // The user's email address
92+
$user->name; // The user's name
93+
$user->nickname; // The user's nickname/username
94+
$user->avatar; // The user's avatar URL
95+
$user->provider; // The OAuth provider name
96+
$user->raw; // Raw user data from the OAuth provider
97+
```
98+
99+
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.
100+
101+
## Configuring a provider
102+
103+
Most providers require only a `clientId`, `clientSecret` and `redirectUri`, but some might need other parameters. A typical configuration looks like the following:
104+
105+
```php app/Auth/github.config.php
106+
return new GitHubOAuthConfig(
107+
clientId: env('GITHUB_CLIENT_ID'),
108+
clientSecret: env('GITHUB_CLIENT_SECRET'),
109+
redirectUri: [GitHubOAuthController::class, 'callback'],
110+
scopes: ['user:email'],
111+
);
112+
```
113+
114+
Note that the `redirectUri` 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.
115+
116+
### Supporting multiple providers
117+
118+
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.
119+
120+
It's a good practice to use an enum for the tag:
121+
122+
```php app/Auth/Provider.php
123+
enum Provider
124+
{
125+
case GITHUB;
126+
case GOOGLE;
127+
case DISCORD;
128+
}
129+
```
130+
131+
```php app/Auth/github.config.php
132+
return new GitHubOAuthConfig(
133+
tag: Provider::GITHUB,
134+
clientId: env('GITHUB_CLIENT_ID'),
135+
clientSecret: env('GITHUB_CLIENT_SECRET'),
136+
redirectUri: [OAuthController::class, 'handleGitHubCallback'],
137+
scopes: ['user:email'],
138+
);
139+
```
140+
141+
```php app/Auth/google.config.php
142+
return new GoogleOAuthConfig(
143+
tag: Provider::GOOGLE,
144+
clientId: env('GOOGLE_CLIENT_ID'),
145+
clientSecret: env('GOOGLE_CLIENT_SECRET'),
146+
redirectUri: [GoogleOAuthController::class, 'handleGoogleCallback'],
147+
);
148+
```
149+
150+
Once you have configured your OAuth providers and your tags, you may inject the {b`Tempest\Auth\OAuth\OAuthClient`} interface using the corresponding tag:
151+
152+
```php app/AuthController.php
153+
use Tempest\Container\Tag;
154+
155+
final readonly class AuthController
156+
{
157+
public function __construct(
158+
#[Tag(OAuthProvider::GITHUB)]
159+
private OAuthClient $githubClient,
160+
#[Tag(OAuthProvider::GOOGLE)]
161+
private OAuthClient $googleClient,
162+
) {}
163+
164+
#[Get('/auth/github')]
165+
public function redirectToGitHub(): Redirect
166+
{
167+
// ...
168+
169+
return new Redirect($this->githubClient->getAuthorizationUrl());
170+
}
171+
172+
#[Get('/auth/github/callback')]
173+
public function handleGitHubCallback(Request $request): Redirect
174+
{
175+
$githubUser = $this->githubClient->handleCallback($request->get('code'));
176+
177+
// ...
178+
}
179+
180+
// Do the same for Google
181+
}
182+
```
183+
184+
### Using a generic provider
185+
186+
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`}:
187+
188+
```php app/Auth/custom.config.php
189+
return new GenericOAuthConfig(
190+
clientId: env('CUSTOM_CLIENT_ID'),
191+
clientSecret: env('CUSTOM_CLIENT_SECRET'),
192+
redirectUri: [OAuthController::class, 'handleCallback'],
193+
urlAuthorize: 'https://provider.com/oauth/authorize',
194+
urlAccessToken: 'https://provider.com/oauth/token',
195+
urlResourceOwnerDetails: 'https://provider.com/api/user',
196+
scopes: ['read:user'],
197+
);
198+
```
199+
200+
### Available providers
201+
202+
Tempest provides a different configuration object for each OAuth provider. Below are the ones that are currently supported:
203+
204+
- **GitHub** authentication using {b`Tempest\Auth\OAuth\Config\GitHubOAuthConfig`},
205+
- **Google** authentication using {b`Tempest\Auth\OAuth\Config\GoogleOAuthConfig`},
206+
- **Facebook** authentication using {b`Tempest\Auth\OAuth\Config\FacebookOAuthConfig`},
207+
- **Discord** authentication using {b`Tempest\Auth\OAuth\Config\DiscordOAuthConfig`},
208+
- **Instagram** authentication using {b`Tempest\Auth\OAuth\Config\InstagramOAuthConfig`},
209+
- **LinkedIn** authentication using {b`Tempest\Auth\OAuth\Config\LinkedInOAuthConfig`},
210+
- **Microsoft** authentication using {b`Tempest\Auth\OAuth\Config\MicrosoftOAuthConfig`},
211+
- **Slack** authentication using {b`Tempest\Auth\OAuth\Config\SlackOAuthConfig`},
212+
- **Apple** authentication using {b`Tempest\Auth\OAuth\Config\AppleOAuthConfig`},
213+
- Any other OAuth platform using {b`Tempest\Auth\OAuth\Config\GenericOAuthConfig`}.
214+
215+
## Testing
216+
217+
By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the OAuth testing utilities through the `oauth` property.
218+
219+
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.
220+
221+
### Faking an OAuth client
222+
223+
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.
224+
225+
```php tests/AuthControllerTest.php
226+
$oauth = $this->oauth->fake(new OAuthUser(
227+
id: 'jon',
228+
229+
nickname: 'jondoe',
230+
));
231+
```
232+
233+
Below is an example of a complete testing flow for an OAuth authentication:
234+
235+
```php tests/AuthControllerTest.php
236+
final class OAuthControllerTest extends IntegrationTestCase
237+
{
238+
#[Test]
239+
public function ensure_oauth(): void
240+
{
241+
// We create a fake OAuth client that will return
242+
// the specified user when the OAuth flow is completed
243+
$oauth = $this->oauth->fake(new OAuthUser(
244+
id: 'jon',
245+
246+
nickname: 'jondoe',
247+
));
248+
249+
// We first simulate a call to the endpoint
250+
// that redirects to the provider
251+
$this->http
252+
->get('/oauth/discord')
253+
->assertRedirect();
254+
255+
// We check that the authorization URL was generated,
256+
// optionally specifying scopes and options
257+
$oauth->assertAuthorizationUrlGenerated();
258+
259+
// We then simulate the callback from the provider
260+
// with a fake code and the expected state
261+
$this->http
262+
->get("/oauth/discord/callback?code=some-fake-code&state={$oauth->getState()}")
263+
->assertRedirect();
264+
265+
// We assert that an access token was retrieved
266+
// with the same fake code we provided before
267+
$oauth->assertUserFetched(code: 'some-fake-code');
268+
269+
// Finally, we ensure an user was created with the
270+
// credentials we specified in the fake OAuth user
271+
$user = query(User::class)
272+
->find(discord_id: 'jon')
273+
->first();
274+
275+
$this->assertInstanceOf(User::class, $user);
276+
$this->assertSame('[email protected]', $user->email);
277+
$this->assertSame('jondoe', $user->username);
278+
}
279+
}
280+
```

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)