Skip to content

Commit 33fb5bf

Browse files
committed
feat(auth): OAuth installer
1 parent a1679a1 commit 33fb5bf

15 files changed

+440
-17
lines changed

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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Auth\Installer;
6+
7+
use Symfony\Component\Process\Process;
8+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
9+
use Tempest\Core\PublishesFiles;
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 install(): void
26+
{
27+
$providers = $this->getProviders();
28+
29+
if (count($providers) === 0) {
30+
return;
31+
}
32+
33+
$this->publishStubs(...$providers);
34+
35+
if ($this->confirm('Would you like to add the OAuth config variables to your .env file?', default: true)) {
36+
$this->updateEnvFile(...$providers);
37+
}
38+
39+
if ($this->confirm('Install composer dependencies?', default: true)) {
40+
$this->installComposerDependencies(...$providers);
41+
}
42+
43+
$this->console->instructions([
44+
sprintf('<strong>The selected OAuth %s installed in your project</strong>', count($providers) > 1 ? 'providers are' : 'provider is'),
45+
'',
46+
'Next steps:',
47+
'1. Update the .env file with your OAuth credentials',
48+
'2. Review and customize the published files if needed',
49+
'',
50+
'<strong>Published files</strong>',
51+
...arr($this->publishedFiles)->map(fn (string $file) => '<style="fg-green">→</style> ' . $file),
52+
]);
53+
}
54+
55+
/**
56+
* @return list<SupportedOAuthProvider>
57+
*/
58+
private function getProviders(): array
59+
{
60+
return $this->ask(
61+
question: 'Please choose an OAuth provider',
62+
options: SupportedOAuthProvider::cases(),
63+
multiple: true,
64+
);
65+
}
66+
67+
private function publishStubs(SupportedOAuthProvider ...$providers): void
68+
{
69+
foreach ($providers as $provider) {
70+
$this->publishController($provider);
71+
72+
$this->publishConfig($provider);
73+
74+
$this->publishImports();
75+
}
76+
}
77+
78+
private function publishConfig(SupportedOAuthProvider $provider): void
79+
{
80+
$name = strtolower($provider->name);
81+
$source = __DIR__ . "/../Installer/oauth/{$name}.config.stub.php";
82+
83+
$this->publish(
84+
source: $source,
85+
destination: src_path("Authentication/OAuth/{$name}.config.php"),
86+
);
87+
}
88+
89+
private function publishController(SupportedOAuthProvider $provider): void
90+
{
91+
$fileName = str($provider->value)
92+
->classBasename()
93+
->replace('Provider', '')
94+
->append('Controller.php')
95+
->toString();
96+
97+
$this->publish(
98+
source: __DIR__ . '/oauth/OAuthControllerStub.php',
99+
destination: src_path("Authentication/OAuth/{$fileName}"),
100+
callback: function (string $source, string $destination) use ($provider) {
101+
$providerFqcn = $provider::class;
102+
$name = strtolower($provider->name);
103+
$userModelFqcn = to_fqcn(src_path('Authentication/User.php'), root: root_path());
104+
105+
$this->update(
106+
path: $destination,
107+
callback: fn (ImmutableString $contents) => $contents->replace(
108+
search: [
109+
"'tag_name'",
110+
'redirect-route',
111+
'callback-route',
112+
"'user-model-fqcn'",
113+
'provider_db_column',
114+
],
115+
replace: [
116+
"\\{$providerFqcn}::{$provider->name}",
117+
"/auth/{$name}",
118+
"/auth/{$name}/callback",
119+
"\\{$userModelFqcn}::class",
120+
"{$name}_id",
121+
],
122+
),
123+
);
124+
},
125+
);
126+
}
127+
128+
private function installComposerDependencies(SupportedOAuthProvider ...$providers): void
129+
{
130+
$packages = arr($providers)
131+
->map(fn (SupportedOAuthProvider $provider) => $provider->composerPackage())
132+
->filter();
133+
134+
if ($packages->isNotEmpty()) {
135+
$this->task(
136+
label: "Installing composer dependencies {$packages->implode(', ')}",
137+
handler: new Process(['composer', 'require', ...$packages], cwd: root_path()),
138+
);
139+
}
140+
}
141+
142+
private function updateEnvFile(SupportedOAuthProvider ...$providers): void
143+
{
144+
arr($providers)
145+
->map(fn (SupportedOAuthProvider $provider) => $this->extractSettings($provider))
146+
->filter()
147+
->flatten()
148+
->each(function (string $setting) {
149+
foreach (['.env', '.env.example'] as $envFile) {
150+
$this->update(
151+
path: root_path($envFile),
152+
callback: static fn (ImmutableString $contents): ImmutableString => $contents->contains($setting)
153+
? $contents
154+
: $contents->append(PHP_EOL, "{$setting}="),
155+
ignoreNonExisting: true,
156+
);
157+
}
158+
});
159+
}
160+
161+
private function extractSettings(SupportedOAuthProvider $provider): array
162+
{
163+
$name = strtolower($provider->name);
164+
$configPath = __DIR__ . "/../Installer/oauth/{$name}.config.stub.php";
165+
166+
try {
167+
return str(read_file($configPath))
168+
->matchAll("/env\('(OAUTH_[^']*)'/", matches: 1)
169+
->map(fn (array $matches) => $matches[1] ?? null)
170+
->filter()
171+
->toArray();
172+
} catch (PathWasNotFound|PathWasNotReadable) {
173+
return [];
174+
}
175+
}
176+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
$this->oauth->authenticate(
36+
request: $request,
37+
map: fn (OAuthUser $user): Authenticatable => query('user-model-fqcn')->updateOrCreate([
38+
'provider_db_column' => $user->id,
39+
], [
40+
'provider_db_column' => $user->id,
41+
'username' => $user->nickname,
42+
'email' => $user->email,
43+
]),
44+
);
45+
46+
return new Redirect('/');
47+
}
48+
}
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+
);
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\GoogleOAuthConfig;
6+
use Tempest\Auth\OAuth\SupportedOAuthProvider;
7+
8+
use function Tempest\env;
9+
10+
return new GoogleOAuthConfig(
11+
clientId: env('OAUTH_GOOGLE_CLIENT_ID') ?? '',
12+
clientSecret: env('OAUTH_GOOGLE_CLIENT_SECRET') ?? '',
13+
redirectTo: [\Tempest\Auth\Installer\oauth\OAuthControllerStub::class, 'callback'],
14+
tag: SupportedOAuthProvider::GOOGLE,
15+
);

0 commit comments

Comments
 (0)