From a894386287a2024dca8c943a7f0a82dee9b6f61e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 15:12:30 -0400 Subject: [PATCH 01/25] avoiding finding by email as its not secure enough --- src/OAuth/Provider.php | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index ae2be009b7a..aabd615351b 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -62,14 +62,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()); } /** From f8d7d2c0d24aaf84b339c42ea45cc2c847a9ab43 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 15:19:17 -0400 Subject: [PATCH 02/25] avoid creating user if already exists --- lang/en/messages.php | 1 + src/Exceptions/OAuthEmailExistsException.php | 13 +++++++++++++ src/Http/Controllers/OAuthController.php | 9 ++++++++- src/OAuth/Provider.php | 5 +++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/OAuthEmailExistsException.php diff --git a/lang/en/messages.php b/lang/en/messages.php index 4012417e947..19e8c76da99 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -188,6 +188,7 @@ '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.', '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/src/Exceptions/OAuthEmailExistsException.php b/src/Exceptions/OAuthEmailExistsException.php new file mode 100644 index 00000000000..f4bde94018c --- /dev/null +++ b/src/Exceptions/OAuthEmailExistsException.php @@ -0,0 +1,13 @@ +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) { diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index aabd615351b..f9182a67091 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; @@ -74,6 +75,10 @@ public function createUser($socialite): StatamicUser { $user = $this->makeUser($socialite); + if (User::findByEmail($user->email())) { + throw new OAuthEmailExistsException($user->email()); + } + $user->save(); $this->setUserProviderId($user, $socialite->getId()); From 7eeff59b7be2240b6921096a048d341f494710eb Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 15:53:39 -0400 Subject: [PATCH 03/25] hitting routes while being logged in links the account --- lang/en/messages.php | 4 +++ src/Http/Controllers/OAuthController.php | 42 ++++++++++++++++++++++-- src/OAuth/Provider.php | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lang/en/messages.php b/lang/en/messages.php index 19e8c76da99..ee616696661 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -189,6 +189,10 @@ '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_link_already_connected' => 'Your :provider account is already connected.', + 'oauth_link_belongs_to_another_user' => 'This :provider account is already connected to a different user.', + 'oauth_link_unsupported' => 'This provider does not support connecting accounts.', + 'oauth_linked' => 'Connected 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/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index fd4538987f1..d06d7685f4c 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -47,6 +47,14 @@ public function handleProviderCallback(Request $request, string $provider) return $this->redirectToProvider($request, $provider); } + $guard = $request->session()->get('statamic.oauth.guard'); + + // When already authenticated, the callback is a request to link + // a provider to the current account rather than to sign in. + if (Auth::guard($guard)->check()) { + return $this->linkProvider($oauth, $providerUser, Auth::guard($guard)->user()); + } + if ($user = $oauth->findUser($providerUser)) { if (config('statamic.oauth.merge_user_data', true)) { $user = $oauth->mergeUser($user, $providerUser); @@ -64,8 +72,7 @@ public function handleProviderCallback(Request $request, string $provider) if ($user) { session()->put('oauth-provider', $provider); - Auth::guard($request->session()->get('statamic.oauth.guard')) - ->login($user, config('statamic.oauth.remember_me', true)); + Auth::guard($guard)->login($user, config('statamic.oauth.remember_me', true)); session()->elevate(); @@ -75,6 +82,37 @@ public function handleProviderCallback(Request $request, string $provider) return redirect()->to($this->unauthorizedRedirectUrl()); } + protected function linkProvider($oauth, $providerUser, $user) + { + // Linking relies on the stateful "state" parameter to protect against + // forced-linking CSRF, so it cannot be done with a stateless provider. + if (Arr::get($oauth->config(), 'stateless', false)) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('error', __('statamic::messages.oauth_link_unsupported')); + } + + $existingUserId = $oauth->getUserId($providerUser->getId()); + + if ($existingUserId === $user->id()) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('success', __('statamic::messages.oauth_link_already_connected', ['provider' => $oauth->label()])); + } + + if ($existingUserId) { + return redirect() + ->to($this->successRedirectUrl()) + ->with('error', __('statamic::messages.oauth_link_belongs_to_another_user', ['provider' => $oauth->label()])); + } + + $oauth->setUserProviderId($user, $providerUser->getId()); + + return redirect() + ->to($this->successRedirectUrl()) + ->with('success', __('statamic::messages.oauth_linked', ['provider' => $oauth->label()])); + } + protected function successRedirectUrl() { $default = '/'; diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index f9182a67091..e38fb75de03 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -167,7 +167,7 @@ protected function setIds($ids) File::put($this->storagePath(), $contents); } - protected function setUserProviderId($user, $id) + public function setUserProviderId($user, $id) { $ids = $this->getIds(); From a44d4cf29161df0ad2ccdfd35b976db8dc05abdc Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 15:53:42 -0400 Subject: [PATCH 04/25] tests --- tests/OAuth/OAuthCallbackTest.php | 182 ++++++++++++++++++++++++++++++ tests/OAuth/ProviderTest.php | 32 ++++++ 2 files changed, 214 insertions(+) create mode 100644 tests/OAuth/OAuthCallbackTest.php diff --git a/tests/OAuth/OAuthCallbackTest.php b/tests/OAuth/OAuthCallbackTest.php new file mode 100644 index 00000000000..7a55aa43c22 --- /dev/null +++ b/tests/OAuth/OAuthCallbackTest.php @@ -0,0 +1,182 @@ +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_links_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)->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_linked', ['provider' => 'Test'])); + } + + #[Test] + public function linking_a_provider_already_linked_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)->hitCallback('test'); + + $this->assertEquals('user-1', $this->provider('test')->getUserId('sub-1')); + $response->assertSessionHas('success', __('statamic::messages.oauth_link_already_connected', ['provider' => 'Test'])); + } + + #[Test] + public function it_does_not_link_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)->hitCallback('test'); + + // Still belongs to the original owner. + $this->assertEquals('other', $this->provider('test')->getUserId('sub-1')); + $response->assertSessionHas('error', __('statamic::messages.oauth_link_belongs_to_another_user', ['provider' => 'Test'])); + } + + #[Test] + public function it_does_not_link_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)->hitCallback('stateless'); + + $this->assertNull($this->provider('stateless')->getUserId('sub-1')); + $response->assertSessionHas('error', __('statamic::messages.oauth_link_unsupported')); + } + + 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/ProviderTest.php b/tests/OAuth/ProviderTest.php index 917aba151e6..45fd443f343 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,30 @@ 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->assertEquals('foo@bar.com', $e->email); + $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 +159,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 linked 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 +191,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 +213,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()); From 6c6ad78adb168f2e9fee570986c3857bfa6f9946 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 16:03:05 -0400 Subject: [PATCH 05/25] unlinking --- config/oauth.php | 1 + lang/en/messages.php | 1 + routes/web.php | 3 ++ src/Http/Controllers/OAuthController.php | 13 +++++ src/OAuth/Provider.php | 9 ++++ tests/OAuth/OAuthUnlinkTest.php | 68 ++++++++++++++++++++++++ tests/OAuth/ProviderTest.php | 16 ++++++ 7 files changed, 111 insertions(+) create mode 100644 tests/OAuth/OAuthUnlinkTest.php diff --git a/config/oauth.php b/config/oauth.php index d7e3fd2488e..509db5699ab 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -13,6 +13,7 @@ 'routes' => [ 'login' => 'oauth/{provider}', 'callback' => 'oauth/{provider}/callback', + 'unlink' => 'oauth/{provider}/unlink', ], /* diff --git a/lang/en/messages.php b/lang/en/messages.php index ee616696661..723fcf1e52d 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -193,6 +193,7 @@ 'oauth_link_belongs_to_another_user' => 'This :provider account is already connected to a different user.', 'oauth_link_unsupported' => 'This provider does not support connecting accounts.', 'oauth_linked' => 'Connected your :provider account.', + 'oauth_unlinked' => '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/routes/web.php b/routes/web.php index 4fc1f673da4..38fb6485e57 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.unlink'), [OAuthController::class, 'unlink']) + ->middleware('auth') + ->name('oauth.unlink'); } }); diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index d06d7685f4c..a53003117e6 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -82,6 +82,19 @@ public function handleProviderCallback(Request $request, string $provider) return redirect()->to($this->unauthorizedRedirectUrl()); } + public function unlink(Request $request, string $provider) + { + $oauth = OAuth::provider($provider); + + if (! $oauth) { + throw new NotFoundHttpException(); + } + + $oauth->forgetUser($request->user()); + + return back()->with('success', __('statamic::messages.oauth_unlinked', ['provider' => $oauth->label()])); + } + protected function linkProvider($oauth, $providerUser, $user) { // Linking relies on the stateful "state" parameter to protect against diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index e38fb75de03..ee8e8c2cdf9 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -176,6 +176,15 @@ public function setUserProviderId($user, $id) $this->setIds($ids); } + public function forgetUser($user) + { + $ids = $this->getIds(); + + unset($ids[$user->id()]); + + $this->setIds($ids); + } + protected function storagePath() { return storage_path("statamic/oauth/{$this->name}.php"); diff --git a/tests/OAuth/OAuthUnlinkTest.php b/tests/OAuth/OAuthUnlinkTest.php new file mode 100644 index 00000000000..3235a4c6ceb --- /dev/null +++ b/tests/OAuth/OAuthUnlinkTest.php @@ -0,0 +1,68 @@ +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 it_unlinks_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)->delete(route('statamic.oauth.unlink', 'test')); + + $response->assertSessionHas('success', __('statamic::messages.oauth_unlinked', ['provider' => 'Test'])); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + + // Another user's link to the same provider is untouched. + $this->assertEquals('user-2', (new Provider('test'))->getUserId('sub-2')); + } + + #[Test] + public function guests_cannot_unlink() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + (new Provider('test'))->setUserProviderId($user, 'sub-1'); + + $this->deleteJson(route('statamic.oauth.unlink', 'test'))->assertUnauthorized(); + + $this->assertEquals('user-1', (new Provider('test'))->getUserId('sub-1')); + } + + #[Test] + public function the_unlink_route_requires_a_delete_request_and_authentication() + { + $route = app('router')->getRoutes()->getByName('statamic.oauth.unlink'); + + // A non-GET, CSRF-protected verb behind the auth middleware. + $this->assertContains('DELETE', $route->methods()); + $this->assertNotContains('GET', $route->methods()); + $this->assertContains('auth', $route->gatherMiddleware()); + } +} diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index 45fd443f343..423e88652e7 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -257,6 +257,22 @@ public function it_gets_the_user_by_id_after_merging_data() $this->assertEquals('foo', $provider->getUserId('foo-bar')); } + #[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->assertNull($provider->getUserId('one-sub')); + $this->assertEquals('two', $provider->getUserId('two-sub')); + } + private function provider() { return new Provider('test'); From 382d5942d101f958a1f83e4c3a1fa742e867402d Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 17:05:25 -0400 Subject: [PATCH 06/25] cp page --- resources/js/components/users/PublishForm.vue | 2 + resources/js/pages/users/Edit.vue | 2 + resources/js/pages/users/OAuth.vue | 61 +++++++++++++++++++ routes/cp.php | 6 ++ .../Controllers/CP/Auth/OAuthController.php | 32 ++++++++++ .../Controllers/CP/Users/UsersController.php | 2 + src/Http/Controllers/OAuthController.php | 5 ++ src/OAuth/Provider.php | 5 ++ tests/OAuth/OAuthUnlinkTest.php | 13 ++++ tests/OAuth/ProviderTest.php | 13 ++++ 10 files changed, 141 insertions(+) create mode 100644 resources/js/pages/users/OAuth.vue create mode 100644 src/Http/Controllers/CP/Auth/OAuthController.php diff --git a/resources/js/components/users/PublishForm.vue b/resources/js/components/users/PublishForm.vue index e0fcd99e480..1b5e2499a70 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'; + +defineProps(['providers']); + +const columns = [ + { label: __('Provider'), field: 'label' }, + { label: '', field: 'actions' }, +]; + +function disconnect(provider) { + axios.delete(provider.unlinkUrl).then(() => { + toast.success(__('statamic::messages.oauth_unlinked', { provider: provider.label })); + router.reload(); + }); +} + + + diff --git a/routes/cp.php b/routes/cp.php index 090cad7e62b..ee03b09ed05 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::get('themes', [ThemeController::class, 'index']); Route::get('themes/refresh', [ThemeController::class, 'refresh']); Route::post('themes/share', ShareThemeController::class); diff --git a/src/Http/Controllers/CP/Auth/OAuthController.php b/src/Http/Controllers/CP/Auth/OAuthController.php new file mode 100644 index 00000000000..f5418b1ea22 --- /dev/null +++ b/src/Http/Controllers/CP/Auth/OAuthController.php @@ -0,0 +1,32 @@ + OAuth::providers()->map(fn (Provider $provider) => [ + 'name' => $provider->name(), + 'label' => $provider->label(), + 'icon' => Statamic::svg('oauth/'.$provider->name()), + 'connected' => $provider->isLinkedTo($user), + 'connectUrl' => $provider->loginUrl().'?redirect='.$redirect, + 'unlinkUrl' => route('statamic.oauth.unlink', $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 a53003117e6..6d1bb768f7d 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -2,6 +2,7 @@ namespace Statamic\Http\Controllers; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; @@ -92,6 +93,10 @@ public function unlink(Request $request, string $provider) $oauth->forgetUser($request->user()); + if ($request->wantsJson()) { + return new JsonResponse([], 204); + } + return back()->with('success', __('statamic::messages.oauth_unlinked', ['provider' => $oauth->label()])); } diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index ee8e8c2cdf9..4eeddad576a 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -185,6 +185,11 @@ public function forgetUser($user) $this->setIds($ids); } + public function isLinkedTo($user): bool + { + return array_key_exists($user->id(), $this->getIds()); + } + protected function storagePath() { return storage_path("statamic/oauth/{$this->name}.php"); diff --git a/tests/OAuth/OAuthUnlinkTest.php b/tests/OAuth/OAuthUnlinkTest.php index 3235a4c6ceb..f5fdbd74e27 100644 --- a/tests/OAuth/OAuthUnlinkTest.php +++ b/tests/OAuth/OAuthUnlinkTest.php @@ -44,6 +44,19 @@ public function it_unlinks_a_provider_from_the_authenticated_user() $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) + ->deleteJson(route('statamic.oauth.unlink', 'test')) + ->assertNoContent(); + + $this->assertNull((new Provider('test'))->getUserId('sub-1')); + } + #[Test] public function guests_cannot_unlink() { diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index 423e88652e7..c156bf924ce 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -257,6 +257,19 @@ public function it_gets_the_user_by_id_after_merging_data() $this->assertEquals('foo', $provider->getUserId('foo-bar')); } + #[Test] + public function it_determines_whether_a_user_is_linked() + { + $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'); + + $this->assertTrue($provider->isLinkedTo($one)); + $this->assertFalse($provider->isLinkedTo($two)); + } + #[Test] public function it_forgets_a_user() { From 7a6a2c9f2269fbd3836261ec0eb7ddb33f31a9e5 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 26 Jun 2026 17:35:29 -0400 Subject: [PATCH 07/25] avoid rendering stateless providers as they cant be used for linking. dry method. --- .../Controllers/CP/Auth/OAuthController.php | 18 ++++++++++-------- src/Http/Controllers/OAuthController.php | 2 +- src/OAuth/Provider.php | 7 ++++++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Http/Controllers/CP/Auth/OAuthController.php b/src/Http/Controllers/CP/Auth/OAuthController.php index f5418b1ea22..d7300075c68 100644 --- a/src/Http/Controllers/CP/Auth/OAuthController.php +++ b/src/Http/Controllers/CP/Auth/OAuthController.php @@ -19,14 +19,16 @@ public function index() $redirect = parse_url(cp_route('oauth'))['path']; return Inertia::render('users/OAuth', [ - 'providers' => OAuth::providers()->map(fn (Provider $provider) => [ - 'name' => $provider->name(), - 'label' => $provider->label(), - 'icon' => Statamic::svg('oauth/'.$provider->name()), - 'connected' => $provider->isLinkedTo($user), - 'connectUrl' => $provider->loginUrl().'?redirect='.$redirect, - 'unlinkUrl' => route('statamic.oauth.unlink', $provider->name()), - ])->values(), + 'providers' => 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->isLinkedTo($user), + 'connectUrl' => $provider->loginUrl().'?redirect='.$redirect, + 'unlinkUrl' => route('statamic.oauth.unlink', $provider->name()), + ])->values(), ]); } } diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index 6d1bb768f7d..ea63cf354a6 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -104,7 +104,7 @@ protected function linkProvider($oauth, $providerUser, $user) { // Linking relies on the stateful "state" parameter to protect against // forced-linking CSRF, so it cannot be done with a stateless provider. - if (Arr::get($oauth->config(), 'stateless', false)) { + if ($oauth->isStateless()) { return redirect() ->to($this->successRedirectUrl()) ->with('error', __('statamic::messages.oauth_link_unsupported')); diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index 4eeddad576a..bd9945053cf 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -27,7 +27,7 @@ public function getSocialiteUser() { $driver = Socialite::driver($this->name); - if (Arr::get($this->config, 'stateless', false)) { + if ($this->isStateless()) { $driver->stateless(); } @@ -190,6 +190,11 @@ public function isLinkedTo($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"); From 8330558fc32a2eb554c5661eb57a3bc052e16d5a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 15:43:40 -0400 Subject: [PATCH 08/25] bust opcache --- src/OAuth/Provider.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index bd9945053cf..21d16106b38 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -165,6 +165,13 @@ 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. Linking a provider + // will show unlinked momentarily and vice versa. + if (function_exists('opcache_invalidate')) { + opcache_invalidate($this->storagePath(), true); + } } public function setUserProviderId($user, $id) From dd85be8c89d65cf001275f7c5301aade3932d4ea Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 15:52:50 -0400 Subject: [PATCH 09/25] oauth loop tag and unlink_form tag --- src/OAuth/Tags.php | 73 +++++++++++++++++ tests/OAuth/OAuthTagsTest.php | 142 ++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/OAuth/OAuthTagsTest.php diff --git a/src/OAuth/Tags.php b/src/OAuth/Tags.php index 9d0394d3d29..6bb6c6f8698 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->isLinkedTo($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 unlink a provider from the current user. + * + * Maps to {{ oauth:unlink_form }} + * + * @return string + */ + public function unlinkForm() + { + $provider = $this->params->get(['provider', 'for']); + + if (! $provider || ! User::current()) { + return ''; + } + + $action = route('statamic.oauth.unlink', $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/OAuthTagsTest.php b/tests/OAuth/OAuthTagsTest.php new file mode 100644 index 00000000000..c05fef7c193 --- /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_links() + { + $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_linkable_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_unlink_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:unlink_form provider="test" }}{{ /oauth:unlink_form }}'); + + $this->assertStringContainsString('
assertStringContainsString('name="_token"', $output); + $this->assertStringContainsString('name="_method" value="DELETE"', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function the_unlink_form_is_empty_for_a_guest() + { + $output = $this->tag('{{ oauth:unlink_form provider="test" }}{{ /oauth:unlink_form }}'); + + $this->assertEquals('', $output); + } + + #[Test] + public function the_unlink_form_is_empty_without_a_provider() + { + $user = UserFacade::make()->id('user-1')->email('one@example.com')->save(); + $this->actingAs($user); + + $output = $this->tag('{{ oauth:unlink_form }}{{ /oauth:unlink_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 }}')); + } +} From cb35267c2ab2ee8f1e5e5a7a9b710d48ebda0e95 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 16:03:20 -0400 Subject: [PATCH 10/25] more tests --- tests/OAuth/OAuthCallbackTest.php | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/OAuth/OAuthCallbackTest.php b/tests/OAuth/OAuthCallbackTest.php index 7a55aa43c22..0e4535b8cc0 100644 --- a/tests/OAuth/OAuthCallbackTest.php +++ b/tests/OAuth/OAuthCallbackTest.php @@ -133,6 +133,53 @@ public function it_does_not_link_a_stateless_provider() $response->assertSessionHas('error', __('statamic::messages.oauth_link_unsupported')); } + #[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(); + } + private function hitCallback(string $provider) { return $this From 3e1588ce7e33b3187ccaafc96688512bbf9dca1b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 16:46:38 -0400 Subject: [PATCH 11/25] 2fa --- src/Http/Controllers/OAuthController.php | 45 +++++++++++--- tests/OAuth/OAuthCallbackTest.php | 74 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index ea63cf354a6..388bc97846a 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -7,9 +7,11 @@ 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; @@ -71,6 +73,12 @@ public function handleProviderCallback(Request $request, string $provider) } if ($user) { + // 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); + } + session()->put('oauth-provider', $provider); Auth::guard($guard)->login($user, config('statamic.oauth.remember_me', true)); @@ -159,19 +167,42 @@ protected function unauthorizedRedirectUrl() // accessing the CP. If they were, we'll redirect them to // the unauthorized page in the CP. Otherwise, to home. - $default = '/'; + return $this->isCpRequest() ? cp_route('unauthorized') : '/'; + } + + protected function twoFactorChallenge($user) + { + session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => config('statamic.oauth.remember_me', true), + ]); + + // Preserve the OAuth destination so the challenge sends the user + // there once they've passed two-factor. + redirect()->setIntendedUrl($this->successRedirectUrl()); + + 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() + { $previous = session('_previous.url'); if (! $query = Arr::get(parse_url($previous), 'query')) { - return $default; + return false; } parse_str($query, $query); - if (! $redirect = Arr::get($query, 'redirect')) { - return $default; - } - - return $redirect === '/'.config('statamic.cp.route') ? cp_route('unauthorized') : $default; + return Arr::get($query, 'redirect') === '/'.config('statamic.cp.route'); } } diff --git a/tests/OAuth/OAuthCallbackTest.php b/tests/OAuth/OAuthCallbackTest.php index 0e4535b8cc0..6cc868149c9 100644 --- a/tests/OAuth/OAuthCallbackTest.php +++ b/tests/OAuth/OAuthCallbackTest.php @@ -2,8 +2,13 @@ namespace Tests\OAuth; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Event; use Mockery; use PHPUnit\Framework\Attributes\Test; +use Statamic\Auth\TwoFactor\RecoveryCode; +use Statamic\Contracts\Auth\TwoFactor\TwoFactorAuthenticationProvider; +use Statamic\Events\TwoFactorAuthenticationChallenged; use Statamic\Facades\OAuth; use Statamic\Facades\User as UserFacade; use Statamic\OAuth\Provider; @@ -180,6 +185,75 @@ public function a_guest_with_a_new_email_is_not_created_when_user_creation_is_di $response->assertRedirect(); } + #[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'); + 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', + '_previous' => ['url' => 'http://localhost/oauth/test?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); + } + + 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 From 5590e2da809ec1a822b4be702c467e70a8a44c16 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 16:54:41 -0400 Subject: [PATCH 12/25] test for the cp page --- tests/OAuth/OAuthPageTest.php | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/OAuth/OAuthPageTest.php diff --git a/tests/OAuth/OAuthPageTest.php b/tests/OAuth/OAuthPageTest.php new file mode 100644 index 00000000000..82e40d2317a --- /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.unlinkUrl', route('statamic.oauth.unlink', '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')); + } +} From 53b924c1dd57485aa3598b309af99fe0f66772b2 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 17:14:51 -0400 Subject: [PATCH 13/25] clean up --- src/OAuth/Provider.php | 5 +++-- tests/OAuth/ProviderTest.php | 14 -------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index 21d16106b38..57f12f03a22 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -45,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)) { @@ -103,8 +106,6 @@ public function mergeUser($user, $socialite): StatamicUser $user->save(); - $this->setUserProviderId($user, $socialite->getId()); - return $user; } diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index c156bf924ce..09058e12306 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -243,20 +243,6 @@ public function it_creates_a_user_via_find_or_create_user_method() $this->assertEquals($user->id(), $provider->getUserId('foo-bar')); } - #[Test] - public function it_gets_the_user_by_id_after_merging_data() - { - $provider = $this->provider(); - - $user = UserFacade::make()->id('foo')->email('foo@bar.com')->data(['name' => 'foo', 'extra' => 'bar'])->save(); - - $this->assertNull($provider->getUserId('foo-bar')); - - $provider->mergeUser($user, $this->socialite()); - - $this->assertEquals('foo', $provider->getUserId('foo-bar')); - } - #[Test] public function it_determines_whether_a_user_is_linked() { From 5fd443c69df090eb3c65c1881530b68434b0a144 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 29 Jun 2026 17:48:16 -0400 Subject: [PATCH 14/25] consistent naming --- config/oauth.php | 2 +- lang/en/messages.php | 10 ++++----- resources/js/components/users/PublishForm.vue | 2 +- resources/js/pages/users/OAuth.vue | 8 +++---- routes/web.php | 4 ++-- .../Controllers/CP/Auth/OAuthController.php | 4 ++-- src/Http/Controllers/OAuthController.php | 22 +++++++++---------- src/OAuth/Provider.php | 6 ++--- src/OAuth/Tags.php | 10 ++++----- tests/OAuth/OAuthCallbackTest.php | 16 +++++++------- ...UnlinkTest.php => OAuthDisconnectTest.php} | 20 ++++++++--------- tests/OAuth/OAuthPageTest.php | 2 +- tests/OAuth/OAuthTagsTest.php | 18 +++++++-------- tests/OAuth/ProviderTest.php | 8 +++---- 14 files changed, 66 insertions(+), 66 deletions(-) rename tests/OAuth/{OAuthUnlinkTest.php => OAuthDisconnectTest.php} (78%) diff --git a/config/oauth.php b/config/oauth.php index 509db5699ab..0f098efa253 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -13,7 +13,7 @@ 'routes' => [ 'login' => 'oauth/{provider}', 'callback' => 'oauth/{provider}/callback', - 'unlink' => 'oauth/{provider}/unlink', + 'disconnect' => 'oauth/{provider}/disconnect', ], /* diff --git a/lang/en/messages.php b/lang/en/messages.php index 723fcf1e52d..b3b3062cb34 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -189,11 +189,11 @@ '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_link_already_connected' => 'Your :provider account is already connected.', - 'oauth_link_belongs_to_another_user' => 'This :provider account is already connected to a different user.', - 'oauth_link_unsupported' => 'This provider does not support connecting accounts.', - 'oauth_linked' => 'Connected your :provider account.', - 'oauth_unlinked' => 'Disconnected your :provider account.', + '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 1b5e2499a70..3df3ed562b2 100644 --- a/resources/js/components/users/PublishForm.vue +++ b/resources/js/components/users/PublishForm.vue @@ -19,7 +19,7 @@ - + { - toast.success(__('statamic::messages.oauth_unlinked', { provider: provider.label })); + axios.delete(provider.disconnectUrl).then(() => { + toast.success(__('statamic::messages.oauth_disconnected', { provider: provider.label })); router.reload(); }); } From 3ab524fe2221fd3ee498d3d9afe3507fec09c74b Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 1 Jul 2026 10:57:15 -0400 Subject: [PATCH 24/25] make sure callback route requires elevation, and store the redirect in session so it survives the re-elevation hops. --- src/Http/Controllers/OAuthController.php | 40 +++++++++----------- tests/OAuth/OAuthCallbackTest.php | 48 +++++++++++++++++++++--- tests/OAuth/OAuthRedirectTest.php | 4 +- 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/src/Http/Controllers/OAuthController.php b/src/Http/Controllers/OAuthController.php index 3f6e3e53bac..dbc25bec9a6 100644 --- a/src/Http/Controllers/OAuthController.php +++ b/src/Http/Controllers/OAuthController.php @@ -13,7 +13,6 @@ 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 __; @@ -40,6 +39,7 @@ public function redirectToProvider(Request $request, string $provider) } $request->session()->put('statamic.oauth.guard', $guard); + $request->session()->put('statamic.oauth.redirect', $request->query('redirect')); return Socialite::driver($provider)->redirect(); } @@ -67,16 +67,21 @@ 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); } - $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. if (Auth::guard($guard)->check()) { return $this->connectProvider($oauth, $providerUser, Auth::guard($guard)->user()); } @@ -164,19 +169,13 @@ protected function connectProvider($oauth, $providerUser, $user) protected function successRedirectUrl() { - $default = '/'; + $redirect = session('statamic.oauth.redirect'); - $previous = session('_previous.url'); - - if (! $query = Arr::get(parse_url($previous), 'query')) { - return $default; + if (! $redirect || URL::isExternalToApplication($redirect)) { + return '/'; } - parse_str($query, $query); - - $redirect = Arr::get($query, 'redirect', $default); - - return URL::isExternalToApplication($redirect) ? $default : $redirect; + return $redirect; } protected function unauthorizedRedirectUrl() @@ -218,14 +217,9 @@ protected function twoFactorChallengeUrl() protected function isCpRequest() { - $previous = session('_previous.url'); - - if (! $query = Arr::get(parse_url($previous), 'query')) { - return false; - } - - parse_str($query, $query); + $redirect = (string) session('statamic.oauth.redirect'); + $cp = '/'.config('statamic.cp.route'); - return Arr::get($query, 'redirect') === '/'.config('statamic.cp.route'); + return $redirect === $cp || Str::startsWith($redirect, $cp.'/'); } } diff --git a/tests/OAuth/OAuthCallbackTest.php b/tests/OAuth/OAuthCallbackTest.php index 75a05ed4bf7..d4e72ad9d0f 100644 --- a/tests/OAuth/OAuthCallbackTest.php +++ b/tests/OAuth/OAuthCallbackTest.php @@ -14,12 +14,13 @@ use Statamic\Facades\OAuth; use Statamic\Facades\User as UserFacade; use Statamic\OAuth\Provider; +use Tests\ElevatesSessions; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class OAuthCallbackTest extends TestCase { - use PreventSavingStacheItemsToDisk; + use ElevatesSessions, PreventSavingStacheItemsToDisk; protected function defineEnvironment($app) { @@ -89,7 +90,7 @@ public function authenticated_user_connects_a_provider() $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); - $response = $this->actingAs($user)->hitCallback('test'); + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('test'); $this->assertEquals('user-1', $this->provider('test')->getUserId('sub-1')); $this->assertCount(1, UserFacade::all()); @@ -105,7 +106,7 @@ public function connecting_a_provider_already_connected_to_the_user_is_idempoten $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); - $response = $this->actingAs($user)->hitCallback('test'); + $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'])); @@ -120,7 +121,7 @@ public function it_does_not_connect_a_provider_identity_owned_by_another_user() $this->fakeProvider('test', [], 'sub-1', 'one@example.com'); - $response = $this->actingAs($user)->hitCallback('test'); + $response = $this->actingAs($user)->withElevatedSession()->hitCallback('test'); // Still belongs to the original owner. $this->assertEquals('other', $this->provider('test')->getUserId('sub-1')); @@ -134,12 +135,47 @@ public function it_does_not_connect_a_stateless_provider() $this->fakeProvider('stateless', ['stateless' => true], 'sub-1', 'one@example.com'); - $response = $this->actingAs($user)->hitCallback('stateless'); + $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() { @@ -232,7 +268,7 @@ public function a_two_factor_challenge_from_the_cp_redirects_to_the_cp_challenge $response = $this ->withSession([ 'statamic.oauth.guard' => 'web', - '_previous' => ['url' => 'http://localhost/oauth/test?redirect=/cp'], + 'statamic.oauth.redirect' => '/cp', ]) ->get(route('statamic.oauth.callback', 'test')); 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()); } From 6176434dc663c03a01544df71cb1c524da94f9d1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 1 Jul 2026 11:12:07 -0400 Subject: [PATCH 25/25] we dont need the email in the exception. unnecessary pii --- src/Exceptions/OAuthEmailExistsException.php | 4 ++-- src/OAuth/Provider.php | 2 +- tests/OAuth/ProviderTest.php | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Exceptions/OAuthEmailExistsException.php b/src/Exceptions/OAuthEmailExistsException.php index f4bde94018c..6e9b1b3275c 100644 --- a/src/Exceptions/OAuthEmailExistsException.php +++ b/src/Exceptions/OAuthEmailExistsException.php @@ -6,8 +6,8 @@ class OAuthEmailExistsException extends Exception { - public function __construct(public readonly ?string $email = null) + public function __construct() { - parent::__construct("A user already exists with the email [{$email}]."); + parent::__construct('A user already exists with the OAuth email.'); } } diff --git a/src/OAuth/Provider.php b/src/OAuth/Provider.php index 699723bd9d6..a345c491648 100644 --- a/src/OAuth/Provider.php +++ b/src/OAuth/Provider.php @@ -79,7 +79,7 @@ public function createUser($socialite): StatamicUser $user = $this->makeUser($socialite); if (User::findByEmail($user->email())) { - throw new OAuthEmailExistsException($user->email()); + throw new OAuthEmailExistsException; } $user->save(); diff --git a/tests/OAuth/ProviderTest.php b/tests/OAuth/ProviderTest.php index d95cd3f6745..0f88c2f63e1 100644 --- a/tests/OAuth/ProviderTest.php +++ b/tests/OAuth/ProviderTest.php @@ -135,7 +135,6 @@ public function it_throws_when_creating_a_user_whose_email_already_exists() $this->provider()->createUser($this->socialite()); $this->fail('Exception was not thrown.'); } catch (OAuthEmailExistsException $e) { - $this->assertEquals('foo@bar.com', $e->email); $this->assertCount(1, UserFacade::all()); $this->assertNull($this->provider()->getUserId('foo-bar')); }