staff = User::factory()->create(); $settings = SocialSettings::for(SocialProvider::Google); $settings->forceFill([ 'provider' => 'google', 'enabled' => true, 'client_id' => 'id', 'client_secret' => 'secret', ])->save(); }); function connect(User $user, SocialIdentity $identity): TestResponse { test()->swap(SocialGateway::class, new FakeSocialGateway($identity)); // Starting the flow is behind password.confirm -- see the route, and // the test below that pins it. confirmPassword($user); test()->actingAs($user)->post(route('connected-accounts.connect', ['provider' => $identity->provider->value])); return test()->actingAs($user)->get(route('social.callback', ['provider' => $identity->provider->value])); } function linkRow(User $user, string $subject, SocialProvider $provider = SocialProvider::Google): SocialAccount { return SocialAccount::query()->create([ 'user_id' => $user->id, 'provider' => $provider->value, 'provider_user_id' => $subject, 'email' => $user->email, ]); } /* |-------------------------------------------------------------------------- | Connecting |-------------------------------------------------------------------------- | | Connecting from here is the safe way to use a provider that cannot | verify an address: the account is established by the session, so the | address is never taken on trust at all. | */ test('a signed-in person connects a provider, unverified address and all', function () { $identity = new SocialIdentity(SocialProvider::Google, 'sub-1', 'anything@elsewhere.test', false, 'Someone'); connect($this->staff, $identity)->assertRedirect(route('connected-accounts.edit')); expect(SocialAccount::query()->where('user_id', $this->staff->id)->count())->toBe(1) ->and(ActivityLog::query()->where('action', Action::SocialAccountLinked)->exists())->toBeTrue(); }); test('a provider already connected to somebody else is refused', function () { $other = User::factory()->client()->create(); linkRow($other, 'sub-1'); connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'x@example.test', true, 'X')) ->assertRedirect(route('connected-accounts.edit')); expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse() ->and(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue(); }); test('reconnecting a different account at the same provider replaces the link', function () { connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'one@example.test', true, 'One')); connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-2', 'two@example.test', true, 'Two')); $links = SocialAccount::query()->where('user_id', $this->staff->id)->get(); expect($links)->toHaveCount(1) ->and($links->first()->provider_user_id)->toBe('sub-2'); }); /* |-------------------------------------------------------------------------- | Starting the exchange |-------------------------------------------------------------------------- | | Connecting starts as an Inertia XHR, and an XHR cannot follow a 302 to | the provider: the browser refuses the cross-origin hop and nobody goes | anywhere. Inertia's 409 + X-Inertia-Location pair is what turns the | same answer into a real top-level navigation. | */ test('connecting from the settings screen navigates the browser, not the XHR', function () { test()->swap(SocialGateway::class, new FakeSocialGateway); confirmPassword($this->staff); $this->actingAs($this->staff) ->post(route('connected-accounts.connect', ['provider' => 'google']), [], ['X-Inertia' => 'true']) ->assertStatus(409) ->assertHeader('X-Inertia-Location', 'https://provider.test/authorize'); }); test('a plain request is still given the provider redirect itself', function () { test()->swap(SocialGateway::class, new FakeSocialGateway); confirmPassword($this->staff); $this->actingAs($this->staff) ->post(route('connected-accounts.connect', ['provider' => 'google'])) ->assertRedirect('https://provider.test/authorize'); }); /* |-------------------------------------------------------------------------- | Disconnecting |-------------------------------------------------------------------------- */ test('a local account can disconnect its only provider', function () { linkRow($this->staff, 'sub-1'); $this->actingAs($this->staff) ->delete(route('connected-accounts.destroy', ['provider' => 'google'])) ->assertSessionHasNoErrors(); expect(SocialAccount::query()->count())->toBe(0) ->and(ActivityLog::query()->where('action', Action::SocialAccountUnlinked)->exists())->toBeTrue(); }); // The mirror image of AccountConversion::requiresNewPassword(): an account // created by a provider holds a random password nobody has ever seen, so // the provider is the only way in. test('an account created by a provider cannot remove its last connection', function () { $client = User::factory()->client()->create(); $client->forceFill(['auth_source' => AuthSource::Social])->save(); linkRow($client, 'sub-1'); $this->actingAs($client) ->delete(route('connected-accounts.destroy', ['provider' => 'google'])) ->assertSessionHasErrors('provider'); expect(SocialAccount::query()->count())->toBe(1); }); // And the instruction inside that refusal, followed. AuthSource says a // social account "may later set a real password"; a reset by emailed token // is where somebody does, and nothing else in the application records it. test('a provider account that resets its password can then disconnect', function () { $client = User::factory()->client()->create(); $client->forceFill(['auth_source' => AuthSource::Social])->save(); linkRow($client, 'sub-1'); $this->post(route('password.store'), [ 'token' => Password::createToken($client), 'email' => $client->email, 'password' => 'a-password-of-her-own', 'password_confirmation' => 'a-password-of-her-own', ])->assertSessionHasNoErrors(); expect($client->fresh()->auth_source)->toBe(AuthSource::Local); $this->actingAs($client->fresh()) ->delete(route('connected-accounts.destroy', ['provider' => 'google'])) ->assertSessionHasNoErrors(); expect(SocialAccount::query()->count())->toBe(0); }); test('it can remove one of two connections', function () { $client = User::factory()->client()->create(); $client->forceFill(['auth_source' => AuthSource::Social])->save(); linkRow($client, 'sub-1'); linkRow($client, 'gh-1', SocialProvider::Github); $this->actingAs($client) ->delete(route('connected-accounts.destroy', ['provider' => 'google'])) ->assertSessionHasNoErrors(); expect(SocialAccount::query()->where('user_id', $client->id)->count())->toBe(1); }); test('one account cannot disconnect another account link', function () { $other = User::factory()->client()->create(); linkRow($other, 'sub-1'); $this->actingAs($this->staff)->delete(route('connected-accounts.destroy', ['provider' => 'google'])); expect(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue(); }); /* |-------------------------------------------------------------------------- | The screen |-------------------------------------------------------------------------- */ test('the screen is reachable by clients as well as staff', function () { $client = User::factory()->client()->create(); $this->actingAs($client)->get('/settings/connected-accounts')->assertOk(); $this->actingAs($this->staff)->get('/settings/connected-accounts')->assertOk(); }); test('a guest is sent to the login page', function () { $this->get('/settings/connected-accounts')->assertRedirect(route('login')); }); test('only usable providers are listed', function () { $this->actingAs($this->staff)->get('/settings/connected-accounts') ->assertInertia(fn ($page) => $page->has('providers', 1)->where('providers.0.provider', 'google')); }); // A SocialAccount row "*is* the authorization to sign in as that account", // so binding one is exactly the kind of thing a stolen session must not be // enough to do -- the same argument routes/settings.php already makes for // the two-factor block and for minting an API token. test('connecting a provider requires a fresh password confirmation', function () { $this->withSession(['auth.password_confirmed_at' => null]); $this->actingAs($this->staff) ->post(route('connected-accounts.connect', ['provider' => 'google'])) ->assertRedirect(route('password.confirm')); expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse(); }); test('a session that never proved the password cannot bind a stranger identity', function () { // The whole attack in one test: the flow is never started, so the // callback has no intent in the session to complete, and nothing is // bound to the victim's account. $attacker = new SocialIdentity(SocialProvider::Google, 'attacker-sub', 'attacker@evil.test', true, 'Attacker'); $this->swap(SocialGateway::class, new FakeSocialGateway($attacker)); $this->withSession(['auth.password_confirmed_at' => null]); $this->actingAs($this->staff) ->post(route('connected-accounts.connect', ['provider' => 'google'])) ->assertRedirect(route('password.confirm')); $this->actingAs($this->staff)->get(route('social.callback', ['provider' => 'google'])); expect(SocialAccount::query() ->where('user_id', $this->staff->id) ->where('provider_user_id', 'attacker-sub') ->exists())->toBeFalse(); }); test('disconnecting stays reachable without one', function () { // Deliberately outside the gate: it removes a way in rather than // adding one, and destroy() already refuses to remove the last. An // account provisioned by a provider has no password to confirm with, // so requiring one here would strand exactly them. linkRow($this->staff, 'google-sub'); $this->withSession(['auth.password_confirmed_at' => null]); $this->actingAs($this->staff) ->delete(route('connected-accounts.destroy', ['provider' => 'google'])) ->assertRedirect() ->assertSessionHasNoErrors(); expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse(); });