diff --git a/composer.json b/composer.json index 910d28b14e1..38cd24a1ed6 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "fakerphp/faker": "~1.10", "google/cloud-translate": "^1.6", "laravel/pint": "1.16.0", + "laravel/socialite": "^5.28", "mockery/mockery": "^1.6.10", "orchestra/testbench": "^10.8 || ^11.0", "phpunit/phpunit": "^11.5.3", diff --git a/config/oauth.php b/config/oauth.php index d7e3fd2488e..0f098efa253 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -13,6 +13,7 @@ 'routes' => [ 'login' => 'oauth/{provider}', 'callback' => 'oauth/{provider}/callback', + 'disconnect' => 'oauth/{provider}/disconnect', ], /* diff --git a/lang/en/messages.php b/lang/en/messages.php index 4012417e947..b3b3062cb34 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -188,6 +188,12 @@ 'navigation_documentation_instructions' => 'Learn about building, configuring, and rendering navigations', 'navigation_link_to_entry_instructions' => 'Add a link to an entry. Enable linking to additional collections in the config area.', 'navigation_link_to_url_instructions' => 'Add a link to any internal or external URL. Enable linking to entries in the config area.', + 'oauth_email_exists' => 'An account with this email address already exists. Sign in and connect this provider from your account settings.', + 'oauth_already_connected' => 'Your :provider account is already connected.', + 'oauth_belongs_to_another_user' => 'This :provider account is already connected to a different user.', + 'oauth_connect_unsupported' => 'This provider does not support connecting accounts.', + 'oauth_connected' => 'Connected your :provider account.', + 'oauth_disconnected' => 'Disconnected your :provider account.', 'outpost_error_422' => 'Error communicating with statamic.com.', 'outpost_error_429' => 'Too many requests to statamic.com.', 'outpost_issue_try_later' => 'There was an issue communicating with statamic.com. Please try again later.', diff --git a/resources/js/components/users/PublishForm.vue b/resources/js/components/users/PublishForm.vue index e0fcd99e480..3df3ed562b2 100644 --- a/resources/js/components/users/PublishForm.vue +++ b/resources/js/components/users/PublishForm.vue @@ -19,6 +19,7 @@ + +import { router } from '@inertiajs/vue3'; +import axios from 'axios'; +import Head from '@/pages/layout/Head.vue'; +import { Header, Button, Listing } from '@ui'; +import { toast } from '@api'; +import { requireElevatedSession } from '@/components/elevated-sessions'; + +defineProps(['providers']); + +const columns = [ + { label: __('Provider'), field: 'label' }, + { label: '', field: 'actions' }, +]; + +function connect(provider) { + requireElevatedSession() + .then(() => (window.location = provider.connectUrl)) + .catch(() => toast.error(__('statamic::messages.elevated_session_required'))); +} + +function disconnect(provider) { + requireElevatedSession() + .then(() => performDisconnect(provider)) + .catch(() => toast.error(__('statamic::messages.elevated_session_required'))); +} + +function performDisconnect(provider) { + axios.delete(provider.disconnectUrl).then(() => { + toast.success(__('statamic::messages.oauth_disconnected', { provider: provider.label })); + router.reload(); + }); +} + + + diff --git a/resources/svg/icons/sign-in.svg b/resources/svg/icons/sign-in.svg index 3e96a5e1f8a..a2c57216266 100644 --- a/resources/svg/icons/sign-in.svg +++ b/resources/svg/icons/sign-in.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/routes/cp.php b/routes/cp.php index 090cad7e62b..2227c479a98 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -1,6 +1,7 @@ name('passkeys.destroy'); }); + if (OAuth::enabled()) { + Route::get('oauth', [OAuthController::class, 'index'])->name('oauth'); + Route::delete('oauth/{provider}/disconnect', [FrontendOAuthController::class, 'disconnect'])->middleware(RequireElevatedSession::class)->name('oauth.disconnect'); + } + Route::get('themes', [ThemeController::class, 'index']); Route::get('themes/refresh', [ThemeController::class, 'refresh']); Route::post('themes/share', ShareThemeController::class); diff --git a/routes/web.php b/routes/web.php index 4fc1f673da4..a87bccc7eb8 100755 --- a/routes/web.php +++ b/routes/web.php @@ -114,6 +114,9 @@ Route::match(['get', 'post'], config('statamic.oauth.routes.callback'), [OAuthController::class, 'handleProviderCallback']) ->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']) ->name('oauth.callback'); + Route::delete(config('statamic.oauth.routes.disconnect', 'oauth/{provider}/disconnect'), [OAuthController::class, 'disconnect']) + ->middleware([AuthGuard::class, 'auth', RequireElevatedSession::class]) + ->name('oauth.disconnect'); } }); diff --git a/src/Exceptions/OAuthEmailExistsException.php b/src/Exceptions/OAuthEmailExistsException.php new file mode 100644 index 00000000000..6e9b1b3275c --- /dev/null +++ b/src/Exceptions/OAuthEmailExistsException.php @@ -0,0 +1,13 @@ + OAuth::providers() + ->reject(fn (Provider $provider) => $provider->isStateless()) + ->map(fn (Provider $provider) => [ + 'name' => $provider->name(), + 'label' => $provider->label(), + 'icon' => Statamic::svg('oauth/'.$provider->name()), + 'connected' => $provider->isConnectedTo($user), + 'connectUrl' => $provider->loginUrl().'?redirect='.$redirect, + 'disconnectUrl' => cp_route('oauth.disconnect', $provider->name()), + ])->values(), + ]); + } +} diff --git a/src/Http/Controllers/CP/Users/UsersController.php b/src/Http/Controllers/CP/Users/UsersController.php index 5cb78449dd2..5b9fddd7e42 100644 --- a/src/Http/Controllers/CP/Users/UsersController.php +++ b/src/Http/Controllers/CP/Users/UsersController.php @@ -9,6 +9,7 @@ use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Action; use Statamic\Facades\CP\Toast; +use Statamic\Facades\OAuth; use Statamic\Facades\Scope; use Statamic\Facades\Search; use Statamic\Facades\TwoFactor; @@ -279,6 +280,7 @@ public function edit(Request $request, $user) 'editBlueprint' => cp_route('blueprints.users.edit'), ], 'canEditBlueprint' => User::current()->can('configure fields'), + 'oauthEnabled' => OAuth::enabled(), 'canEditPassword' => User::fromUser($request->user())->can('editPassword', $user), 'requiresCurrentPassword' => $isCurrentUser = $request->user()->id === $user->id(), 'itemActions' => Action::for($user, ['view' => 'form']), diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index 6215f8e6f7b..dbc25bec9a6 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -2,16 +2,21 @@ namespace Statamic\Http\Controllers; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\InvalidStateException; +use Statamic\Events\TwoFactorAuthenticationChallenged; use Statamic\Exceptions\NotFoundHttpException; +use Statamic\Exceptions\OAuthEmailExistsException; use Statamic\Facades\OAuth; +use Statamic\Facades\TwoFactor; use Statamic\Facades\URL; -use Statamic\Support\Arr; use Statamic\Support\Str; +use function Statamic\trans as __; + class OAuthController { public function redirectToProvider(Request $request, string $provider) @@ -23,15 +28,37 @@ public function redirectToProvider(Request $request, string $provider) throw new NotFoundHttpException(); } - if (Str::startsWith(parse_url($referer)['path'], Str::ensureLeft(config('statamic.cp.route'), '/'))) { + $isCp = Str::startsWith(parse_url($referer)['path'], Str::ensureLeft(config('statamic.cp.route'), '/')); + + if ($isCp) { $guard = config('statamic.users.guards.cp', 'web'); } + if (Auth::guard($guard)->check() && $response = $this->requireElevatedSession($request, $isCp)) { + return $response; + } + $request->session()->put('statamic.oauth.guard', $guard); + $request->session()->put('statamic.oauth.redirect', $request->query('redirect')); return Socialite::driver($provider)->redirect(); } + private function requireElevatedSession(Request $request, bool $isCp) + { + if (! config('statamic.users.elevated_sessions_enabled') || $request->hasElevatedSession()) { + return null; + } + + if ($request->wantsJson()) { + return response()->json(['message' => __('Requires an elevated session.')], 403); + } + + $challenge = $isCp ? cp_route('confirm-password') : route('statamic.elevated-session'); + + return redirect()->setIntendedUrl($request->fullUrl())->to($challenge); + } + public function handleProviderCallback(Request $request, string $provider) { $oauth = OAuth::provider($provider); @@ -40,25 +67,49 @@ public function handleProviderCallback(Request $request, string $provider) throw new NotFoundHttpException(); } + $guard = $request->session()->get('statamic.oauth.guard'); + + // When already authenticated, the callback is a request to connect a provider to the current + // account rather than to sign in. We'll enforce an elevated session *before* the session + // state gets consumed so the connection can succeed after they complete re-elevation. + if (Auth::guard($guard)->check() && $response = $this->requireElevatedSession($request, $this->isCpRequest())) { + return $response; + } + try { $providerUser = $oauth->getSocialiteUser(); } catch (InvalidStateException $e) { return $this->redirectToProvider($request, $provider); } + if (Auth::guard($guard)->check()) { + return $this->connectProvider($oauth, $providerUser, Auth::guard($guard)->user()); + } + if ($user = $oauth->findUser($providerUser)) { if (config('statamic.oauth.merge_user_data', true)) { $user = $oauth->mergeUser($user, $providerUser); } } elseif (config('statamic.oauth.create_user', true)) { - $user = $oauth->createUser($providerUser); + try { + $user = $oauth->createUser($providerUser); + } catch (OAuthEmailExistsException $e) { + return redirect() + ->to($this->unauthorizedRedirectUrl()) + ->with('error', __('statamic::messages.oauth_email_exists')); + } } if ($user) { session()->put('oauth-provider', $provider); - Auth::guard($request->session()->get('statamic.oauth.guard')) - ->login($user, config('statamic.oauth.remember_me', true)); + // OAuth must not bypass two-factor. When the user has it enabled, + // defer the login and send them through the challenge instead. + if (TwoFactor::enabled() && $user->hasEnabledTwoFactorAuthentication()) { + return $this->twoFactorChallenge($user); + } + + Auth::guard($guard)->login($user, config('statamic.oauth.remember_me', true)); session()->elevate(); @@ -68,21 +119,63 @@ public function handleProviderCallback(Request $request, string $provider) return redirect()->to($this->unauthorizedRedirectUrl()); } - protected function successRedirectUrl() + public function disconnect(Request $request, string $provider) { - $default = '/'; + $oauth = OAuth::provider($provider); + + if (! $oauth) { + throw new NotFoundHttpException(); + } - $previous = session('_previous.url'); + $oauth->forgetUser($request->user()); - if (! $query = Arr::get(parse_url($previous), 'query')) { - return $default; + if ($request->wantsJson()) { + return new JsonResponse([], 204); } - parse_str($query, $query); + return back()->with('success', __('statamic::messages.oauth_disconnected', ['provider' => $oauth->label()])); + } - $redirect = Arr::get($query, 'redirect', $default); + protected function connectProvider($oauth, $providerUser, $user) + { + // Connecting relies on the stateful "state" parameter to protect against + // forced-connection CSRF, so it cannot be done with a stateless provider. + if ($oauth->isStateless()) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('error', __('statamic::messages.oauth_connect_unsupported')); + } + + $existingUserId = $oauth->getUserId($providerUser->getId()); + + if ($existingUserId === $user->id()) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('success', __('statamic::messages.oauth_already_connected', ['provider' => $oauth->label()])); + } + + if ($existingUserId) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('error', __('statamic::messages.oauth_belongs_to_another_user', ['provider' => $oauth->label()])); + } + + $oauth->setUserProviderId($user, $providerUser->getId()); + + return redirect() + ->to($this->successRedirectUrl()) + ->with('success', __('statamic::messages.oauth_connected', ['provider' => $oauth->label()])); + } + + protected function successRedirectUrl() + { + $redirect = session('statamic.oauth.redirect'); - return URL::isExternalToApplication($redirect) ? $default : $redirect; + if (! $redirect || URL::isExternalToApplication($redirect)) { + return '/'; + } + + return $redirect; } protected function unauthorizedRedirectUrl() @@ -96,19 +189,37 @@ protected function unauthorizedRedirectUrl() // accessing the CP. If they were, we'll redirect them to // the unauthorized page in the CP. Otherwise, to home. - $default = '/'; - $previous = session('_previous.url'); + return $this->isCpRequest() ? cp_route('unauthorized') : '/'; + } - if (! $query = Arr::get(parse_url($previous), 'query')) { - return $default; - } + protected function twoFactorChallenge($user) + { + session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => config('statamic.oauth.remember_me', true), + ]); - parse_str($query, $query); + // Preserve the OAuth destination so the challenge sends the user + // there once they've passed two-factor. + redirect()->setIntendedUrl($this->successRedirectUrl()); - if (! $redirect = Arr::get($query, 'redirect')) { - return $default; - } + TwoFactorAuthenticationChallenged::dispatch($user); + + return redirect($this->twoFactorChallengeUrl()); + } + + protected function twoFactorChallengeUrl() + { + return $this->isCpRequest() + ? cp_route('two-factor-challenge') + : config('statamic.users.two_factor_challenge_url') ?? route('statamic.two-factor-challenge'); + } + + protected function isCpRequest() + { + $redirect = (string) session('statamic.oauth.redirect'); + $cp = '/'.config('statamic.cp.route'); - return $redirect === '/'.config('statamic.cp.route') ? cp_route('unauthorized') : $default; + return $redirect === $cp || Str::startsWith($redirect, $cp.'/'); } } diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index ae2be009b7a..a345c491648 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -7,6 +7,7 @@ use Laravel\Socialite\Contracts\User as SocialiteUser; use Laravel\Socialite\Facades\Socialite; use Statamic\Contracts\Auth\User as StatamicUser; +use Statamic\Exceptions\OAuthEmailExistsException; use Statamic\Facades\File; use Statamic\Facades\User; use Statamic\Support\Str; @@ -26,7 +27,7 @@ public function getSocialiteUser() { $driver = Socialite::driver($this->name); - if (Arr::get($this->config, 'stateless', false)) { + if ($this->isStateless()) { $driver->stateless(); } @@ -44,6 +45,9 @@ public function getUserId(string $id): ?string return array_flip($this->getIds())[$id] ?? null; } + /** + * @deprecated Use findUser() and createUser() directly. + */ public function findOrCreateUser($socialite): StatamicUser { if ($user = $this->findUser($socialite)) { @@ -62,14 +66,7 @@ public function findOrCreateUser($socialite): StatamicUser */ public function findUser($socialite): ?StatamicUser { - if ( - ($user = User::findByOAuthId($this, $socialite->getId())) || - ($user = User::findByEmail($socialite->getEmail())) - ) { - return $user; - } - - return null; + return User::findByOAuthId($this, $socialite->getId()); } /** @@ -81,6 +78,10 @@ public function createUser($socialite): StatamicUser { $user = $this->makeUser($socialite); + if (User::findByEmail($user->email())) { + throw new OAuthEmailExistsException; + } + $user->save(); $this->setUserProviderId($user, $socialite->getId()); @@ -105,8 +106,6 @@ public function mergeUser($user, $socialite): StatamicUser $user->save(); - $this->setUserProviderId($user, $socialite->getId()); - return $user; } @@ -167,9 +166,16 @@ protected function setIds($ids) $contents = 'storagePath(), $contents); + + // Bust the opcache immediately, otherwise it may take a few moments + // for PHP to pick up the changes to the file. Connecting a provider + // will show disconnected momentarily and vice versa. + if (function_exists('opcache_invalidate')) { + opcache_invalidate($this->storagePath(), true); + } } - protected function setUserProviderId($user, $id) + public function setUserProviderId($user, $id) { $ids = $this->getIds(); @@ -178,6 +184,25 @@ protected function setUserProviderId($user, $id) $this->setIds($ids); } + public function forgetUser($user) + { + $ids = $this->getIds(); + + unset($ids[$user->id()]); + + $this->setIds($ids); + } + + public function isConnectedTo($user): bool + { + return array_key_exists($user->id(), $this->getIds()); + } + + public function isStateless(): bool + { + return (bool) Arr::get($this->config, 'stateless', false); + } + protected function storagePath() { return storage_path("statamic/oauth/{$this->name}.php"); diff --git a/src/OAuth/Tags.php b/src/OAuth/Tags.php index 9d0394d3d29..540b76de35c 100644 --- a/src/OAuth/Tags.php +++ b/src/OAuth/Tags.php @@ -3,12 +3,44 @@ namespace Statamic\OAuth; use Statamic\Facades\OAuth; +use Statamic\Facades\User; +use Statamic\Tags\Concerns; use Statamic\Tags\Tags as BaseTags; class Tags extends BaseTags { + use Concerns\RendersForms; + public static $handle = 'oauth'; + /** + * Loop over the available providers. + * + * Maps to {{ oauth }} ... {{ /oauth }} + */ + public function index() + { + $user = User::current(); + + $providers = OAuth::providers() + ->reject(fn (Provider $provider) => $provider->isStateless()) + ->map(fn (Provider $provider) => [ + 'name' => $provider->name(), + 'label' => $provider->label(), + 'connected' => $user ? $provider->isConnectedTo($user) : false, + 'url' => $this->generateLoginUrl($provider->name()), + ]) + ->values(); + + if (! $this->canParseContents()) { + return $providers->all(); + } + + return $providers->isEmpty() + ? $this->parseNoResults() + : $this->parseLoop($providers->all()); + } + /** * Shorthand for generating an OAuth login URL. * @@ -31,6 +63,47 @@ public function loginUrl() return $this->generateLoginUrl($this->params->get(['provider', 'for'])); } + /** + * Output a form to disconnect a provider from the current user. + * + * Maps to {{ oauth:disconnect_form }} + * + * @return string + */ + public function disconnectForm() + { + $provider = $this->params->get(['provider', 'for']); + + if (! $provider || ! User::current()) { + return ''; + } + + $action = route('statamic.oauth.disconnect', $provider); + $method = 'POST'; + + $knownParams = ['provider', 'for']; + + if (! $this->canParseContents()) { + return [ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => array_merge( + $this->formMetaPrefix($this->formParams($method, [])), + ['_method' => 'DELETE'] + ), + ]; + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= ''; + + $html .= $this->parse([]); + + $html .= $this->formClose(); + + return $html; + } + /** * Generate the login URL. * diff --git a/tests/OAuth/OAuthCallbackTest.php b/tests/OAuth/OAuthCallbackTest.php new file mode 100644 index 00000000000..d4e72ad9d0f --- /dev/null +++ b/tests/OAuth/OAuthCallbackTest.php @@ -0,0 +1,377 @@ +set('statamic.oauth.enabled', true); + $app['config']->set('statamic.oauth.providers', [ + 'test' => 'Test', + 'stateless' => ['stateless' => true, 'label' => 'Stateless'], + ]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory(storage_path('statamic/oauth')); + + parent::tearDown(); + } + + #[Test] + public function guest_with_a_new_email_is_created_and_logged_in() + { + $this->assertCount(0, UserFacade::all()); + + $this->fakeProvider('test', [], 'sub-1', 'new@example.com'); + + $this->hitCallback('test'); + + $this->assertCount(1, UserFacade::all()); + $this->assertNotNull($user = UserFacade::findByEmail('new@example.com')); + $this->assertAuthenticatedAs($user); + $this->assertEquals($user->id(), $this->provider('test')->getUserId('sub-1')); + } + + #[Test] + public function guest_matching_an_oauth_id_is_logged_in_without_creating_a_user() + { + $user = UserFacade::make()->id('user-1')->email('existing@example.com')->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com'); + + $this->hitCallback('test'); + + $this->assertCount(1, UserFacade::all()); + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function guest_whose_email_already_exists_is_denied_and_not_logged_in() + { + UserFacade::make()->id('user-1')->email('taken@example.com')->save(); + + // A different oauth id, but an email that already belongs to an account. + $this->fakeProvider('test', [], 'sub-new', 'taken@example.com'); + + $response = $this->hitCallback('test'); + + $this->assertGuest(); + $this->assertCount(1, UserFacade::all()); + $this->assertNull($this->provider('test')->getUserId('sub-new')); + $response->assertSessionHas('error', __('statamic::messages.oauth_email_exists')); + } + + #[Test] + public function authenticated_user_connects_a_provider() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); + + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('test'); + + $this->assertEquals('user-1', $this->provider('test')->getUserId('sub-1')); + $this->assertCount(1, UserFacade::all()); + $this->assertAuthenticatedAs($user); + $response->assertSessionHas('success', __('statamic::messages.oauth_connected', ['provider' => 'Test'])); + } + + #[Test] + public function connecting_a_provider_already_connected_to_the_user_is_idempotent() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); + + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('test'); + + $this->assertEquals('user-1', $this->provider('test')->getUserId('sub-1')); + $response->assertSessionHas('success', __('statamic::messages.oauth_already_connected', ['provider' => 'Test'])); + } + + #[Test] + public function it_does_not_connect_a_provider_identity_owned_by_another_user() + { + $other = UserFacade::make()->id('other')->email('other@example.com')->save(); + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->provider('test')->setUserProviderId($other, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); + + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('test'); + + // Still belongs to the original owner. + $this->assertEquals('other', $this->provider('test')->getUserId('sub-1')); + $response->assertSessionHas('error', __('statamic::messages.oauth_belongs_to_another_user', ['provider' => 'Test'])); + } + + #[Test] + public function it_does_not_connect_a_stateless_provider() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->fakeProvider('stateless', ['stateless' => true], 'sub-1', 'one@example.com'); + + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('stateless'); + + $this->assertNull($this->provider('stateless')->getUserId('sub-1')); + $response->assertSessionHas('error', __('statamic::messages.oauth_connect_unsupported')); + } + + #[Test] + public function an_authenticated_connect_re_checks_the_elevated_session_before_linking() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); + + // Authenticated (a connect) but NOT elevated — e.g. the elevation lapsed + // during the provider round-trip. The callback must re-challenge rather + // than link, and must do so before consuming the single-use state/code. + $response = $this->actingAs($user)->hitCallback('test'); + + $this->assertNull($this->provider('test')->getUserId('sub-1')); + $response->assertRedirect(route('statamic.elevated-session')); + } + + #[Test] + public function a_control_panel_connect_re_checks_against_the_cp_elevation_challenge() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + + // A connect initiated from the CP stashes redirect=/cp/oauth; a lapsed + // elevation must re-challenge in the CP, not bounce to the front-end. + $response = $this + ->actingAs($user) + ->withSession([ + 'statamic.oauth.guard' => 'web', + 'statamic.oauth.redirect' => '/cp/oauth', + ]) + ->get(route('statamic.oauth.callback', 'test')); + + $this->assertNull($this->provider('test')->getUserId('sub-1')); + $response->assertRedirect(cp_route('confirm-password')); + } + + #[Test] + public function logging_in_merges_user_data_when_enabled() + { + $user = UserFacade::make()->id('user-1')->email('existing@example.com')->data(['name' => 'Old Name'])->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com', 'New Name'); + + $this->hitCallback('test'); + + $this->assertAuthenticatedAs($user = UserFacade::find('user-1')); + $this->assertEquals('New Name', $user->get('name')); + } + + #[Test] + public function logging_in_does_not_merge_user_data_when_disabled() + { + config()->set('statamic.oauth.merge_user_data', false); + + $user = UserFacade::make()->id('user-1')->email('existing@example.com')->data(['name' => 'Old Name'])->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com', 'New Name'); + + $this->hitCallback('test'); + + $this->assertAuthenticatedAs($user = UserFacade::find('user-1')); + $this->assertEquals('Old Name', $user->get('name')); + } + + #[Test] + public function a_guest_with_a_new_email_is_not_created_when_user_creation_is_disabled() + { + config()->set('statamic.oauth.create_user', false); + + $this->assertCount(0, UserFacade::all()); + + $this->fakeProvider('test', [], 'sub-1', 'new@example.com'); + + $response = $this->hitCallback('test'); + + $this->assertGuest(); + $this->assertCount(0, UserFacade::all()); + $this->assertNull($this->provider('test')->getUserId('sub-1')); + $response->assertRedirect(); + } + + #[Test] + public function it_remembers_the_oauth_provider_in_the_session_on_login() + { + $user = UserFacade::make()->id('user-1')->email('existing@example.com')->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com'); + + $response = $this->hitCallback('test'); + + $this->assertAuthenticatedAs($user); + $response->assertSessionHas('oauth-provider', 'test'); + } + + #[Test] + public function a_two_factor_enabled_user_is_challenged_instead_of_being_logged_in() + { + Event::fake(); + + $user = $this->userWithTwoFactorEnabled('user-1', 'existing@example.com'); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com'); + + $response = $this->hitCallback('test'); + + $this->assertGuest(); + $response->assertRedirect(route('statamic.two-factor-challenge')); + $response->assertSessionHas('login.id', 'user-1'); + // The provider is remembered through the challenge so re-auth-on-expiry still works. + $response->assertSessionHas('oauth-provider', 'test'); + Event::assertDispatched(TwoFactorAuthenticationChallenged::class, fn ($event) => $event->user->id() === 'user-1'); + } + + #[Test] + public function a_two_factor_challenge_from_the_cp_redirects_to_the_cp_challenge() + { + $user = $this->userWithTwoFactorEnabled('user-1', 'existing@example.com'); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com'); + + $response = $this + ->withSession([ + 'statamic.oauth.guard' => 'web', + 'statamic.oauth.redirect' => '/cp', + ]) + ->get(route('statamic.oauth.callback', 'test')); + + $this->assertGuest(); + $response->assertRedirect(cp_route('two-factor-challenge')); + $response->assertSessionHas('login.id', 'user-1'); + } + + #[Test] + public function a_two_factor_enabled_user_is_logged_in_when_two_factor_is_disabled() + { + config()->set('statamic.users.two_factor_enabled', false); + + Event::fake(); + + $user = $this->userWithTwoFactorEnabled('user-1', 'existing@example.com'); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->fakeProvider('test', [], 'sub-1', 'existing@example.com'); + + $this->hitCallback('test'); + + $this->assertAuthenticatedAs(UserFacade::find('user-1')); + Event::assertNotDispatched(TwoFactorAuthenticationChallenged::class); + } + + #[Test] + public function an_invalid_oauth_state_restarts_the_sign_in() + { + Socialite::fake('test'); + + // The OAuth "state" mismatch (CSRF protection) surfaces as an exception + // from Socialite; the callback should restart the flow, not error. + $provider = Mockery::mock(Provider::class.'[getSocialiteUser]', ['test', []]); + $provider->shouldReceive('getSocialiteUser')->andThrow(new InvalidStateException); + OAuth::partialMock()->shouldReceive('provider')->with('test')->andReturn($provider); + + $this + ->withSession(['statamic.oauth.guard' => 'web']) + ->withHeader('referer', 'http://localhost/') + ->get(route('statamic.oauth.callback', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + + $this->assertGuest(); + } + + private function userWithTwoFactorEnabled(string $id, string $email) + { + $user = UserFacade::make()->id($id)->email($email)->save(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, fn () => RecoveryCode::generate())->all())), + ])->save(); + + return $user; + } + + private function hitCallback(string $provider) + { + return $this + ->withSession(['statamic.oauth.guard' => 'web']) + ->get(route('statamic.oauth.callback', $provider)); + } + + /** + * Replace the provider with a real one whose only mocked method is + * getSocialiteUser(), so the Socialite facade is never called but the + * storage map and user lookups behave for real. + */ + private function fakeProvider(string $name, array $config, string $id, string $email, string $displayName = 'Foo Bar'): void + { + $socialiteUser = new class($id, $email, $displayName) + { + public function __construct(private string $id, private string $email, private string $name) + { + } + + public function getId() + { + return $this->id; + } + + public function getEmail() + { + return $this->email; + } + + public function getName() + { + return $this->name; + } + }; + + $provider = Mockery::mock(Provider::class.'[getSocialiteUser]', [$name, $config]); + $provider->shouldReceive('getSocialiteUser')->andReturn($socialiteUser); + + OAuth::partialMock()->shouldReceive('provider')->with($name)->andReturn($provider); + } + + private function provider(string $name): Provider + { + return new Provider($name); + } +} diff --git a/tests/OAuth/OAuthConnectInitiationTest.php b/tests/OAuth/OAuthConnectInitiationTest.php new file mode 100644 index 00000000000..bcb6d511112 --- /dev/null +++ b/tests/OAuth/OAuthConnectInitiationTest.php @@ -0,0 +1,146 @@ +set('statamic.oauth.enabled', true); + $app['config']->set('statamic.oauth.providers', ['test' => 'Test']); + } + + public function tearDown(): void + { + app('files')->deleteDirectory(storage_path('statamic/oauth')); + + parent::tearDown(); + } + + #[Test] + public function an_authenticated_connect_requires_an_elevated_session_when_enabled() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->actingAs($user) + ->get(route('statamic.oauth.login', 'test')) + ->assertRedirect(route('statamic.elevated-session')); + } + + #[Test] + public function an_authenticated_connect_proceeds_to_the_provider_with_an_elevated_session() + { + Socialite::fake('test'); + + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->actingAs($user) + ->withElevatedSession() + ->get(route('statamic.oauth.login', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + } + + #[Test] + public function an_authenticated_connect_proceeds_when_elevated_sessions_are_disabled() + { + Socialite::fake('test'); + + config(['statamic.users.elevated_sessions_enabled' => false]); + + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + + $this->actingAs($user) + ->get(route('statamic.oauth.login', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + } + + #[Test] + public function an_unauthenticated_sign_in_is_not_gated_by_elevated_sessions() + { + Socialite::fake('test'); + + // A guest hitting the same route is signing in, not connecting, so the + // elevation requirement must not apply even though it's enabled. + $this->get(route('statamic.oauth.login', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + } + + #[Test] + public function a_cp_connect_redirects_to_the_cp_elevation_challenge() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + + // The referer tells the controller this connect originated in the CP, so + // the user should land on the CP challenge rather than the front-end one. + $this->actingAs($user) + ->get(route('statamic.oauth.login', 'test'), ['referer' => 'http://localhost/cp/users']) + ->assertRedirect(cp_route('confirm-password')); + } + + #[Test] + public function a_connect_immediately_after_a_fresh_oauth_login_is_already_elevated() + { + Socialite::fake('test'); + + // Logging in via OAuth elevates the session, so an immediate connect + // should sail through without a fresh challenge. + $user = UserFacade::make()->id('user-1')->email('existing@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->fakeProviderForLogin('test', 'sub-1', 'existing@example.com'); + + $this->withSession(['statamic.oauth.guard' => 'web']) + ->get(route('statamic.oauth.callback', 'test')); + + $this->assertAuthenticatedAs(UserFacade::find('user-1')); + + $this->get(route('statamic.oauth.login', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + } + + /** + * Replace the provider so the callback can log a user in for real without + * touching the live Socialite driver. + */ + private function fakeProviderForLogin(string $name, string $id, string $email): void + { + $socialiteUser = new class($id, $email) + { + public function __construct(private string $id, private string $email) + { + } + + public function getId() + { + return $this->id; + } + + public function getEmail() + { + return $this->email; + } + + public function getName() + { + return 'Foo Bar'; + } + }; + + $provider = Mockery::mock(Provider::class.'[getSocialiteUser]', [$name, []]); + $provider->shouldReceive('getSocialiteUser')->andReturn($socialiteUser); + + OAuth::partialMock()->shouldReceive('provider')->with($name)->andReturn($provider); + } +} diff --git a/tests/OAuth/OAuthDisconnectTest.php b/tests/OAuth/OAuthDisconnectTest.php new file mode 100644 index 00000000000..c38bcc39fbd --- /dev/null +++ b/tests/OAuth/OAuthDisconnectTest.php @@ -0,0 +1,230 @@ +set('statamic.oauth.enabled', true); + $app['config']->set('statamic.oauth.providers', ['test' => 'Test']); + } + + protected function publishedConfigWithoutDisconnectRoute($app) + { + // An older published config will have a `routes` array without the + // `disconnect` key. Config is merged shallowly, so the key won't be + // backfilled from ours — the route must fall back to a default. + $app['config']->set('statamic.oauth.routes', [ + 'login' => 'oauth/{provider}', + 'callback' => 'oauth/{provider}/callback', + ]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory(storage_path('statamic/oauth')); + + parent::tearDown(); + } + + #[Test] + public function it_disconnects_a_provider_from_the_authenticated_user() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $other = UserFacade::make()->id('user-2')->email('two@example.com')->save(); + $provider = new Provider('test'); + $provider->setUserProviderId($user, 'sub-1'); + $provider->setUserProviderId($other, 'sub-2'); + + $response = $this->actingAs($user)->withElevatedSession()->delete(route('statamic.oauth.disconnect', 'test')); + + $response->assertSessionHas('success', __('statamic::messages.oauth_disconnected', ['provider' => 'Test'])); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + + // Another user's connection to the same provider is untouched. + $this->assertEquals('user-2', (new Provider('test'))->getUserId('sub-2')); + } + + #[Test] + public function it_returns_no_content_for_json_requests() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->withElevatedSession() + ->deleteJson(route('statamic.oauth.disconnect', 'test')) + ->assertNoContent(); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function guests_cannot_disconnect() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->deleteJson(route('statamic.oauth.disconnect', 'test'))->assertUnauthorized(); + + $this->assertEquals('user-1', (new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + #[DefineEnvironment('publishedConfigWithoutDisconnectRoute')] + public function the_disconnect_route_falls_back_when_absent_from_a_published_config() + { + $this->assertTrue(Route::has('statamic.oauth.disconnect')); + $this->assertEquals(url('oauth/test/disconnect'), route('statamic.oauth.disconnect', 'test')); + } + + #[Test] + public function the_front_end_disconnect_route_requires_a_delete_request_and_the_web_guard() + { + $route = app('router')->getRoutes()->getByName('statamic.oauth.disconnect'); + + // A non-GET, CSRF-protected verb authenticated against the front-end guard. + $this->assertContains('DELETE', $route->methods()); + $this->assertNotContains('GET', $route->methods()); + $this->assertContains(AuthGuard::class, $route->gatherMiddleware()); + $this->assertContains('auth', $route->gatherMiddleware()); + } + + #[Test] + public function a_control_panel_user_can_disconnect_through_the_cp_route() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->withElevatedSession() + ->delete(cp_route('oauth.disconnect', 'test')) + ->assertRedirect(); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_cp_disconnect_route_is_a_delete_behind_control_panel_auth() + { + $route = app('router')->getRoutes()->getByName('statamic.cp.oauth.disconnect'); + + $this->assertNotNull($route); + $this->assertContains('DELETE', $route->methods()); + $this->assertNotContains('GET', $route->methods()); + $this->assertContains('statamic.cp.authenticated', $route->gatherMiddleware()); + } + + #[Test] + public function the_front_end_disconnect_requires_an_elevated_session_when_enabled() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->delete(route('statamic.oauth.disconnect', 'test')) + ->assertRedirect(route('statamic.elevated-session')); + + // Still connected — the disconnect was blocked. + $this->assertEquals('user-1', (new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_front_end_disconnect_returns_a_403_for_json_when_not_elevated() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->deleteJson(route('statamic.oauth.disconnect', 'test')) + ->assertJson(['message' => 'Requires an elevated session.']) + ->assertStatus(403); + + $this->assertEquals('user-1', (new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_front_end_disconnect_succeeds_with_an_elevated_session() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->withElevatedSession() + ->delete(route('statamic.oauth.disconnect', 'test')) + ->assertSessionHas('success', __('statamic::messages.oauth_disconnected', ['provider' => 'Test'])); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_front_end_disconnect_succeeds_without_a_challenge_when_elevated_sessions_are_disabled() + { + config(['statamic.users.elevated_sessions_enabled' => false]); + + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->delete(route('statamic.oauth.disconnect', 'test')) + ->assertSessionHas('success', __('statamic::messages.oauth_disconnected', ['provider' => 'Test'])); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_cp_disconnect_requires_an_elevated_session_when_enabled() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->delete(cp_route('oauth.disconnect', 'test')) + ->assertRedirect(cp_route('confirm-password')); + + $this->assertEquals('user-1', (new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_cp_disconnect_succeeds_with_an_elevated_session() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->withElevatedSession() + ->delete(cp_route('oauth.disconnect', 'test')) + ->assertRedirect(); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_cp_disconnect_succeeds_without_a_challenge_when_elevated_sessions_are_disabled() + { + config(['statamic.users.elevated_sessions_enabled' => false]); + + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user) + ->delete(cp_route('oauth.disconnect', 'test')) + ->assertRedirect(); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } +} diff --git a/tests/OAuth/OAuthLoginTest.php b/tests/OAuth/OAuthLoginTest.php new file mode 100644 index 00000000000..fed8e09637d --- /dev/null +++ b/tests/OAuth/OAuthLoginTest.php @@ -0,0 +1,58 @@ +set('statamic.oauth.enabled', true); + $app['config']->set('statamic.oauth.providers', ['test' => 'Test']); + + // Give the control panel a distinct guard so the referer-based guard + // selection is actually observable (both default to "web" otherwise). + $app['config']->set('statamic.users.guards.cp', 'cp'); + $app['config']->set('statamic.users.guards.web', 'web'); + $app['config']->set('auth.guards.cp', $app['config']->get('auth.guards.web')); + } + + #[Test] + public function it_redirects_to_the_provider() + { + Socialite::fake('test'); + + $this->get(route('statamic.oauth.login', 'test')) + ->assertRedirect('https://socialite.fake/test/authorize'); + } + + #[Test] + public function a_front_end_request_stores_the_web_guard() + { + Socialite::fake('test'); + + $this->get(route('statamic.oauth.login', 'test')) + ->assertSessionHas('statamic.oauth.guard', 'web'); + } + + #[Test] + public function a_control_panel_request_stores_the_cp_guard() + { + Socialite::fake('test'); + + $this->get(route('statamic.oauth.login', 'test'), ['referer' => 'http://localhost/cp/dashboard']) + ->assertSessionHas('statamic.oauth.guard', 'cp'); + } + + #[Test] + public function it_404s_for_an_unknown_provider() + { + $this->get(route('statamic.oauth.login', 'unknown'))->assertNotFound(); + } +} diff --git a/tests/OAuth/OAuthPageTest.php b/tests/OAuth/OAuthPageTest.php new file mode 100644 index 00000000000..b4abd39d0fe --- /dev/null +++ b/tests/OAuth/OAuthPageTest.php @@ -0,0 +1,78 @@ +set('statamic.oauth.providers', [ + 'test' => 'Test', + 'another' => 'Another', + 'stateless' => ['stateless' => true, 'label' => 'Stateless'], + ]); + } + + protected function enableOAuth($app) + { + $app['config']->set('statamic.oauth.enabled', true); + } + + public function tearDown(): void + { + app('files')->deleteDirectory(storage_path('statamic/oauth')); + + parent::tearDown(); + } + + private function provider(string $name): Provider + { + return new Provider($name); + } + + #[Test] + #[DefineEnvironment('enableOAuth')] + public function it_renders_the_connected_accounts_page_with_link_state_excluding_stateless_providers() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->makeSuper()->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this + ->actingAs($user) + ->get(cp_route('oauth')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('users/OAuth') + ->has('providers', 2) + ->where('providers.0.name', 'test') + ->where('providers.0.label', 'Test') + ->where('providers.0.connected', true) + ->where('providers.0.disconnectUrl', cp_route('oauth.disconnect', 'test')) + ->where('providers.1.name', 'another') + ->where('providers.1.connected', false) + ); + } + + #[Test] + #[DefineEnvironment('enableOAuth')] + public function the_page_route_is_registered_when_oauth_is_enabled() + { + $this->assertTrue(Route::has('statamic.cp.oauth')); + } + + #[Test] + public function the_page_route_is_not_registered_when_oauth_is_disabled() + { + $this->assertFalse(Route::has('statamic.cp.oauth')); + } +} diff --git a/tests/OAuth/OAuthRedirectTest.php b/tests/OAuth/OAuthRedirectTest.php index 02212a49df6..d6c98110c70 100644 --- a/tests/OAuth/OAuthRedirectTest.php +++ b/tests/OAuth/OAuthRedirectTest.php @@ -11,7 +11,7 @@ class OAuthRedirectTest extends TestCase #[Test] public function it_redirects_to_local_url() { - session(['_previous.url' => 'http://localhost/oauth/test?redirect=/dashboard']); + session(['statamic.oauth.redirect' => '/dashboard']); $this->assertEquals('/dashboard', $this->getSuccessRedirectUrl()); } @@ -19,7 +19,7 @@ public function it_redirects_to_local_url() #[Test] public function it_does_not_redirect_to_external_url() { - session(['_previous.url' => 'http://localhost/oauth/test?redirect=https://evil.com']); + session(['statamic.oauth.redirect' => 'https://evil.com']); $this->assertEquals('/', $this->getSuccessRedirectUrl()); } diff --git a/tests/OAuth/OAuthTagsTest.php b/tests/OAuth/OAuthTagsTest.php new file mode 100644 index 00000000000..178bf7dbc0d --- /dev/null +++ b/tests/OAuth/OAuthTagsTest.php @@ -0,0 +1,142 @@ +set('statamic.oauth.enabled', true); + $app['config']->set('statamic.oauth.providers', [ + 'test' => 'Test', + 'another' => 'Another', + 'stateless' => ['stateless' => true, 'label' => 'Stateless'], + ]); + } + + public function tearDown(): void + { + app('files')->deleteDirectory(storage_path('statamic/oauth')); + + parent::tearDown(); + } + + private function tag($tag, $params = []) + { + return Parse::template($tag, $params, trusted: true); + } + + private function provider(string $name): Provider + { + return new Provider($name); + } + + #[Test] + public function it_loops_over_providers_excluding_stateless_ones() + { + $output = $this->tag('{{ oauth }}{{ name }}:{{ label }};{{ /oauth }}'); + + $this->assertEquals('test:Test;another:Another;', $output); + $this->assertStringNotContainsString('stateless', $output); + } + + #[Test] + public function it_outputs_login_urls() + { + $output = $this->tag('{{ oauth }}{{ url }};{{ /oauth }}'); + + $this->assertEquals( + route('statamic.oauth.login', 'test').';'.route('statamic.oauth.login', 'another').';', + $output + ); + } + + #[Test] + public function it_appends_a_redirect_to_the_login_urls() + { + $output = $this->tag('{{ oauth redirect="/dashboard" }}{{ url }};{{ /oauth }}'); + + $this->assertStringContainsString(route('statamic.oauth.login', 'test').'?redirect=/dashboard;', $output); + } + + #[Test] + public function the_connected_flag_is_false_for_a_guest() + { + $output = $this->tag('{{ oauth }}{{ name }}:{{ if connected }}yes{{ else }}no{{ /if }};{{ /oauth }}'); + + $this->assertEquals('test:no;another:no;', $output); + } + + #[Test] + public function the_connected_flag_reflects_the_current_users_connections() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->provider('test')->setUserProviderId($user, 'sub-1'); + + $this->actingAs($user); + + $output = $this->tag('{{ oauth }}{{ name }}:{{ if connected }}yes{{ else }}no{{ /if }};{{ /oauth }}'); + + $this->assertEquals('test:yes;another:no;', $output); + } + + #[Test] + public function it_outputs_no_results_when_there_are_no_connectable_providers() + { + config()->set('statamic.oauth.providers', [ + 'stateless' => ['stateless' => true, 'label' => 'Stateless'], + ]); + + $output = $this->tag('{{ oauth }}{{ name }}{{ if no_results }}none{{ /if }}{{ /oauth }}'); + + $this->assertEquals('none', $output); + } + + #[Test] + public function the_disconnect_form_renders_a_csrf_protected_delete_form() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->actingAs($user); + + $output = $this->tag('{{ oauth:disconnect_form provider="test" }}{{ /oauth:disconnect_form }}'); + + $this->assertStringContainsString('
assertStringContainsString('name="_token"', $output); + $this->assertStringContainsString('name="_method" value="DELETE"', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function the_disconnect_form_is_empty_for_a_guest() + { + $output = $this->tag('{{ oauth:disconnect_form provider="test" }}{{ /oauth:disconnect_form }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function the_disconnect_form_is_empty_without_a_provider() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->actingAs($user); + + $output = $this->tag('{{ oauth:disconnect_form }}{{ /oauth:disconnect_form }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function the_wildcard_still_outputs_a_login_url() + { + $this->assertEquals(route('statamic.oauth.login', 'test'), $this->tag('{{ oauth:test }}')); + } +} diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index 917aba151e6..0f88c2f63e1 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -3,6 +3,7 @@ namespace Tests\OAuth; use PHPUnit\Framework\Attributes\Test; +use Statamic\Exceptions\OAuthEmailExistsException; use Statamic\Facades\User as UserFacade; use Statamic\OAuth\Provider; use Tests\PreventSavingStacheItemsToDisk; @@ -123,12 +124,29 @@ public function it_creates_a_user() $this->assertEquals($user->id(), $provider->getUserId('foo-bar')); } + #[Test] + public function it_throws_when_creating_a_user_whose_email_already_exists() + { + $this->user()->save(); + + $this->assertCount(1, UserFacade::all()); + + try { + $this->provider()->createUser($this->socialite()); + $this->fail('Exception was not thrown.'); + } catch (OAuthEmailExistsException $e) { + $this->assertCount(1, UserFacade::all()); + $this->assertNull($this->provider()->getUserId('foo-bar')); + } + } + #[Test] public function it_finds_an_existing_user_via_find_user_method() { $provider = $this->provider(); $savedUser = $this->user()->save(); + $provider->setUserProviderId($savedUser, 'foo-bar'); $this->assertCount(1, UserFacade::all()); $this->assertEquals([$savedUser], UserFacade::all()->all()); @@ -140,6 +158,17 @@ public function it_finds_an_existing_user_via_find_user_method() $this->assertEquals($savedUser, $foundUser); } + #[Test] + public function it_does_not_find_a_user_by_email_via_find_user_method() + { + $provider = $this->provider(); + + // A user exists with the same email, but is not connected to the provider. + $this->user()->save(); + + $this->assertNull($provider->findUser($this->socialite())); + } + #[Test] public function it_does_not_find_or_create_a_user_via_find_user_method() { @@ -161,6 +190,7 @@ public function it_finds_an_existing_user_via_find_or_create_user_method() $provider = $this->provider(); $savedUser = $this->user()->save(); + $provider->setUserProviderId($savedUser, 'foo-bar'); $this->assertCount(1, UserFacade::all()); $this->assertEquals([$savedUser], UserFacade::all()->all()); @@ -182,6 +212,7 @@ public function it_finds_an_existing_user_via_find_or_create_user_method_but_doe $provider = $this->provider(); $savedUser = $this->user()->save(); + $provider->setUserProviderId($savedUser, 'foo-bar'); $this->assertCount(1, UserFacade::all()); $this->assertEquals([$savedUser], UserFacade::all()->all()); @@ -212,17 +243,32 @@ public function it_creates_a_user_via_find_or_create_user_method() } #[Test] - public function it_gets_the_user_by_id_after_merging_data() + public function it_determines_whether_a_user_is_connected() { $provider = $this->provider(); - $user = UserFacade::make()->id('foo')->email('foo@bar.com')->data(['name' => 'foo', 'extra' => 'bar'])->save(); + $one = UserFacade::make()->id('one')->email('one@bar.com')->save(); + $two = UserFacade::make()->id('two')->email('two@bar.com')->save(); + $provider->setUserProviderId($one, 'one-sub'); - $this->assertNull($provider->getUserId('foo-bar')); + $this->assertTrue($provider->isConnectedTo($one)); + $this->assertFalse($provider->isConnectedTo($two)); + } - $provider->mergeUser($user, $this->socialite()); + #[Test] + public function it_forgets_a_user() + { + $provider = $this->provider(); + + $one = UserFacade::make()->id('one')->email('one@bar.com')->save(); + $two = UserFacade::make()->id('two')->email('two@bar.com')->save(); + $provider->setUserProviderId($one, 'one-sub'); + $provider->setUserProviderId($two, 'two-sub'); + + $provider->forgetUser($one); - $this->assertEquals('foo', $provider->getUserId('foo-bar')); + $this->assertNull($provider->getUserId('one-sub')); + $this->assertEquals('two', $provider->getUserId('two-sub')); } private function provider() diff --git a/tests/TestCase.php b/tests/TestCase.php index e23b8e2056e..cc9f0f401a1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -69,6 +69,7 @@ protected function getPackageProviders($app) \Wilderborn\Partyline\ServiceProvider::class, \Archetype\ServiceProvider::class, \Spatie\LaravelRay\RayServiceProvider::class, + \Laravel\Socialite\SocialiteServiceProvider::class, ]; }