Skip to content

Commit 9c82b71

Browse files
Bapawebrendt
andauthored
feat(auth): add OAuth installer (#1674)
Co-authored-by: Brent Roose <[email protected]>
1 parent ad2182a commit 9c82b71

17 files changed

+585
-18
lines changed

docs/2-features/17-oauth.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@ This implementation is built on top of the PHP league's [OAuth client](https://g
1212

1313
## Getting started
1414

15-
To get started with OAuth, you will first need to create a configuration file for your desired OAuth provider.
15+
Tempest provides an installer to quickly set up OAuth in your project. You can run the installer using the following command:
16+
17+
```sh
18+
./tempest install auth --oauth
19+
```
20+
21+
The installer will:
22+
- Prompt you to select one or more OAuth providers from the available options
23+
- Publish the necessary configuration files and controller stubs
24+
- Optionally add the OAuth credentials to your `.env` and `.env.example` files
25+
- Optionally install the required Composer dependencies for the selected providers
26+
27+
This is the quickest way to get started with OAuth in your Tempest application.
28+
29+
Alternatively, you can manually create a configuration file for your desired OAuth provider.
1630

1731
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`}:
1832

packages/auth/src/Exceptions/OAuthProviderWasMissing.php

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,8 @@
44

55
namespace Tempest\Auth\Exceptions;
66

7-
use AdamPaterson\OAuth2\Client\Provider\Slack;
87
use Exception;
9-
use League\OAuth2\Client\Provider\Apple;
10-
use League\OAuth2\Client\Provider\Facebook;
11-
use League\OAuth2\Client\Provider\Instagram;
12-
use League\OAuth2\Client\Provider\LinkedIn;
13-
use Stevenmaguire\OAuth2\Client\Provider\Microsoft;
14-
use Wohali\OAuth2\Client\Provider\Discord;
8+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
159

1610
final class OAuthProviderWasMissing extends Exception implements AuthenticationException
1711
{
@@ -28,15 +22,6 @@ public function __construct(
2822

2923
private function getPackageName(): ?string
3024
{
31-
return match ($this->missing) {
32-
Facebook::class => 'league/oauth2-facebook',
33-
Instagram::class => 'league/oauth2-instagram',
34-
LinkedIn::class => 'league/oauth2-linkedin',
35-
Apple::class => 'patrickbussmann/oauth2-apple',
36-
Microsoft::class => 'stevenmaguire/oauth2-microsoft',
37-
Discord::class => 'wohali/oauth2-discord-new',
38-
Slack::class => 'adam-paterson/oauth2-slack',
39-
default => null,
40-
};
25+
return SupportedOAuthProvider::tryFrom($this->missing)?->composerPackage();
4126
}
4227
}

packages/auth/src/Installer/AuthenticationInstaller.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public function install(): void
4040
migration: $this->container->get(to_fqcn($migration, root: root_path())),
4141
);
4242
}
43+
44+
if ($this->shouldInstallOAuth()) {
45+
$this->container->get(OAuthInstaller::class)->install();
46+
}
4347
}
4448

4549
private function shouldMigrate(): bool
@@ -52,5 +56,16 @@ private function shouldMigrate(): bool
5256

5357
return (bool) $argument->value;
5458
}
59+
60+
private function shouldInstallOAuth(): bool
61+
{
62+
$argument = $this->consoleArgumentBag->get('oauth');
63+
64+
if ($argument === null || ! is_bool($argument->value)) {
65+
return $this->console->confirm('Do you want to install OAuth?', default: false);
66+
}
67+
68+
return (bool) $argument->value;
69+
}
5570
}
5671
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Auth\Installer;
6+
7+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
8+
use Tempest\Core\PublishesFiles;
9+
use Tempest\Process\ProcessExecutor;
10+
use Tempest\Support\Filesystem\Exceptions\PathWasNotFound;
11+
use Tempest\Support\Filesystem\Exceptions\PathWasNotReadable;
12+
use Tempest\Support\Str\ImmutableString;
13+
14+
use function Tempest\root_path;
15+
use function Tempest\src_path;
16+
use function Tempest\Support\arr;
17+
use function Tempest\Support\Filesystem\read_file;
18+
use function Tempest\Support\Namespace\to_fqcn;
19+
use function Tempest\Support\str;
20+
21+
final class OAuthInstaller
22+
{
23+
use PublishesFiles;
24+
25+
public function __construct(
26+
private readonly ProcessExecutor $processExecutor,
27+
) {}
28+
29+
public function install(): void
30+
{
31+
$providers = $this->getProviders();
32+
33+
if (count($providers) === 0) {
34+
return;
35+
}
36+
37+
$this->publishStubs(...$providers);
38+
39+
if ($this->confirm('Would you like to add the OAuth config variables to your .env file?', default: true)) {
40+
$this->updateEnvFile(...$providers);
41+
}
42+
43+
if ($this->confirm('Install composer dependencies?', default: true)) {
44+
$this->installComposerDependencies(...$providers);
45+
}
46+
47+
$this->console->instructions([
48+
sprintf('<strong>The selected OAuth %s installed in your project</strong>', count($providers) > 1 ? 'providers are' : 'provider is'),
49+
'',
50+
'Next steps:',
51+
'1. Update the .env file with your OAuth credentials',
52+
'2. Implement the OAuth controller callback method',
53+
'3. Review and customize the published files if needed',
54+
'',
55+
'<strong>Published files</strong>',
56+
...arr($this->publishedFiles)->map(fn (string $file) => '<style="fg-green">→</style> ' . $file),
57+
]);
58+
}
59+
60+
/**
61+
* @return list<SupportedOAuthProvider>
62+
*/
63+
private function getProviders(): array
64+
{
65+
return $this->ask(
66+
question: 'Please choose an OAuth provider',
67+
options: SupportedOAuthProvider::cases(),
68+
multiple: true,
69+
);
70+
}
71+
72+
private function publishStubs(SupportedOAuthProvider ...$providers): void
73+
{
74+
foreach ($providers as $provider) {
75+
$this->publishController($provider);
76+
77+
$this->publishConfig($provider);
78+
79+
$this->publishImports();
80+
}
81+
}
82+
83+
private function publishConfig(SupportedOAuthProvider $provider): void
84+
{
85+
$name = strtolower($provider->name);
86+
$source = __DIR__ . "/../Installer/oauth/{$name}.config.stub.php";
87+
88+
$this->publish(
89+
source: $source,
90+
destination: src_path("Authentication/OAuth/{$name}.config.php"),
91+
);
92+
}
93+
94+
private function publishController(SupportedOAuthProvider $provider): void
95+
{
96+
$fileName = str($provider->value)
97+
->classBasename()
98+
->replace('Provider', '')
99+
->append('Controller.php')
100+
->toString();
101+
102+
$this->publish(
103+
source: __DIR__ . '/oauth/OAuthControllerStub.php',
104+
destination: src_path("Authentication/OAuth/{$fileName}"),
105+
callback: function (string $source, string $destination) use ($provider) {
106+
$providerFqcn = $provider::class;
107+
$name = strtolower($provider->name);
108+
$userModelFqcn = to_fqcn(src_path('Authentication/User.php'), root: root_path());
109+
110+
$this->update(
111+
path: $destination,
112+
callback: fn (ImmutableString $contents) => $contents->replace(
113+
search: [
114+
"'tag_name'",
115+
'redirect-route',
116+
'callback-route',
117+
"'user-model-fqcn'",
118+
'provider_db_column',
119+
],
120+
replace: [
121+
"\\{$providerFqcn}::{$provider->name}",
122+
"/auth/{$name}",
123+
"/auth/{$name}/callback",
124+
"\\{$userModelFqcn}::class",
125+
"{$name}_id",
126+
],
127+
),
128+
);
129+
},
130+
);
131+
}
132+
133+
private function installComposerDependencies(SupportedOAuthProvider ...$providers): void
134+
{
135+
$packages = arr($providers)
136+
->map(fn (SupportedOAuthProvider $provider) => $provider->composerPackage())
137+
->filter();
138+
139+
if ($packages->isNotEmpty()) {
140+
$this->processExecutor->run("composer require {$packages->implode(' ')}");
141+
}
142+
}
143+
144+
private function updateEnvFile(SupportedOAuthProvider ...$providers): void
145+
{
146+
arr($providers)
147+
->map(fn (SupportedOAuthProvider $provider) => $this->extractSettings($provider))
148+
->filter()
149+
->flatten()
150+
->each(function (string $setting) {
151+
foreach (['.env', '.env.example'] as $envFile) {
152+
$this->update(
153+
path: root_path($envFile),
154+
callback: static fn (ImmutableString $contents): ImmutableString => $contents->contains($setting)
155+
? $contents
156+
: $contents->append(PHP_EOL, "{$setting}="),
157+
ignoreNonExisting: true,
158+
);
159+
}
160+
});
161+
}
162+
163+
private function extractSettings(SupportedOAuthProvider $provider): array
164+
{
165+
$name = strtolower($provider->name);
166+
$configPath = __DIR__ . "/../Installer/oauth/{$name}.config.stub.php";
167+
168+
try {
169+
return str(read_file($configPath))
170+
->matchAll("/env\('(OAUTH_[^']*)'/", matches: 1)
171+
->map(fn (array $matches) => $matches[1] ?? null)
172+
->filter()
173+
->toArray();
174+
} catch (PathWasNotFound|PathWasNotReadable) {
175+
return [];
176+
}
177+
}
178+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Auth\Installer\oauth;
6+
7+
use Tempest\Auth\Authentication\Authenticatable;
8+
use Tempest\Auth\OAuth\OAuthClient;
9+
use Tempest\Auth\OAuth\OAuthUser;
10+
use Tempest\Container\Tag;
11+
use Tempest\Discovery\SkipDiscovery;
12+
use Tempest\Http\Request;
13+
use Tempest\Http\Responses\Redirect;
14+
use Tempest\Router\Get;
15+
16+
use function Tempest\Database\query;
17+
18+
#[SkipDiscovery]
19+
final readonly class OAuthControllerStub
20+
{
21+
public function __construct(
22+
#[Tag('tag_name')]
23+
private OAuthClient $oauth,
24+
) {}
25+
26+
#[Get('redirect-route')]
27+
public function redirect(): Redirect
28+
{
29+
return $this->oauth->createRedirect();
30+
}
31+
32+
#[Get('callback-route')]
33+
public function callback(Request $request): Redirect
34+
{
35+
// TODO: implement, the code below is an example
36+
37+
$this->oauth->authenticate(
38+
request: $request,
39+
map: fn (OAuthUser $user): Authenticatable => query('user-model-fqcn')->updateOrCreate([
40+
'provider_db_column' => $user->id,
41+
], [
42+
'provider_db_column' => $user->id,
43+
'username' => $user->nickname,
44+
'email' => $user->email,
45+
]),
46+
);
47+
48+
return new Redirect('/');
49+
}
50+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\Auth\OAuth\Config\AppleOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new AppleOAuthConfig(
11+
clientId: env('OAUTH_APPLE_CLIENT_ID') ?? '',
12+
teamId: env('OAUTH_APPLE_TEAM_ID') ?? '',
13+
keyId: env('OAUTH_APPLE_KEY_ID') ?? '',
14+
keyFile: env('OAUTH_APPLE_KEY_FILE') ?? '',
15+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
16+
tag: SupportedOAuthProvider::APPLE,
17+
);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\Auth\OAuth\Config\DiscordOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new DiscordOAuthConfig(
11+
clientId: env('OAUTH_DISCORD_CLIENT_ID') ?? '',
12+
clientSecret: env('OAUTH_DISCORD_CLIENT_SECRET') ?? '',
13+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
14+
tag: SupportedOAuthProvider::DISCORD,
15+
);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\Auth\OAuth\Config\FacebookOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new FacebookOAuthConfig(
11+
clientId: env('OAUTH_FACEBOOK_CLIENT_ID') ?? '',
12+
clientSecret: env('OAUTH_FACEBOOK_CLIENT_SECRET') ?? '',
13+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
14+
tag: SupportedOAuthProvider::FACEBOOK,
15+
);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\Auth\OAuth\Config\GenericOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new GenericOAuthConfig(
11+
clientId: env('OAUTH_GENERIC_CLIENT_ID') ?? '',
12+
clientSecret: env('OAUTH_GENERIC_CLIENT_SECRET') ?? '',
13+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
14+
urlAuthorize: env('OAUTH_GENERIC_URL_AUTHORIZE') ?? '',
15+
urlAccessToken: env('OAUTH_GENERIC_URL_ACCESS_TOKEN') ?? '',
16+
urlResourceOwnerDetails: env('OAUTH_GENERIC_URL_RESOURCE_OWNER_DETAILS') ?? '',
17+
tag: SupportedOAuthProvider::GENERIC,
18+
);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tempest\Auth\OAuth\Config\GitHubOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new GitHubOAuthConfig(
11+
clientId: env('OAUTH_GITHUB_CLIENT_ID') ?? '',
12+
clientSecret: env('OAUTH_GITHUB_CLIENT_SECRET') ?? '',
13+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
14+
tag: SupportedOAuthProvider::GITHUB,
15+
);

0 commit comments

Comments
 (0)