0, 'is_registration_enabled' => false, ]); Once::flush(); OauthSetting::create([ 'provider' => 'google', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', 'tenant' => 'example.com', 'enabled' => true, ]); }); it('logs in an existing user when the oauth provider returns a mixed-case email', function () { config()->set('app.maintenance.driver', 'file'); $user = User::factory()->create([ 'email' => 'username@example.edu', ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->once()->andReturnSelf(); $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); $provider->shouldReceive('user')->once()->andReturn((object) [ 'email' => 'UserName@example.edu', 'name' => 'Example User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); $response = $this->get(route('auth.callback', 'google')); $response->assertRedirect('/'); $this->assertAuthenticatedAs($user); expect(User::count())->toBe(1); expect(OauthIdentity::where([ 'user_id' => $user->id, 'provider' => 'google', 'provider_user_id' => 'google-user-id', ])->exists())->toBeTrue(); }); it('never moves an existing oauth identity when the provider email changes', function () { config()->set('app.maintenance.driver', 'file'); $identityOwner = User::factory()->create(['email' => 'old@example.com']); $otherUser = User::factory()->create(['email' => 'new@example.com']); $identity = OauthIdentity::create([ 'user_id' => $identityOwner->id, 'provider' => 'google', 'issuer' => 'google', 'provider_user_id' => 'google-user-id', 'email' => 'old@example.com', ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->once()->andReturnSelf(); $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); $provider->shouldReceive('user')->once()->andReturn((object) [ 'email' => 'new@example.com', 'name' => 'Example User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); $this->get(route('auth.callback', 'google'))->assertRedirect('/'); $this->assertAuthenticatedAs($identityOwner); expect($identity->refresh()->user_id)->toBe($identityOwner->id) ->and($identity->email)->toBe('new@example.com') ->and($identity->user_id)->not->toBe($otherUser->id); }); it('keeps an existing oauth identity working without new email verification evidence', function () { $user = User::factory()->create(['email' => 'existing@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'discord', 'issuer' => 'discord', 'provider_user_id' => 'existing-discord-id', 'email' => $user->email, ]); $discordSetting = OauthSetting::create([ 'provider' => 'discord', 'client_id' => 'discord-client-id', 'client_secret' => 'discord-client-secret', 'enabled' => true, ]); $resolvedUser = app(OauthLoginService::class)->login('discord', (object) [ 'email' => 'changed@example.com', 'name' => 'Existing Discord User', 'id' => 'existing-discord-id', 'user' => ['verified' => false], ], $discordSetting); expect($resolvedUser->is($user))->toBeTrue(); $this->assertAuthenticatedAs($user); $this->assertDatabaseHas('oauth_identities', [ 'user_id' => $user->id, 'provider' => 'discord', 'provider_user_id' => 'existing-discord-id', 'email' => 'changed@example.com', ]); }); it('continues oauth login when another request creates the identity first', function () { $user = User::factory()->create(['email' => 'race@example.com']); $eventName = 'eloquent.creating: '.OauthIdentity::class; Event::listen($eventName, function (OauthIdentity $identity): void { $attributes = $identity->getAttributes(); DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes)); throw new UniqueConstraintViolationException( DB::getDefaultConnection(), 'insert into oauth_identities', [], new PDOException('duplicate identity'), ); }); try { $resolvedUser = app(OauthLoginService::class)->login('google', (object) [ 'email' => 'race@example.com', 'name' => 'Race User', 'id' => 'google-race-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ], OauthSetting::where('provider', 'google')->firstOrFail()); } finally { Event::forget($eventName); } expect($resolvedUser->is($user))->toBeTrue() ->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1); $this->assertAuthenticatedAs($user); }); it('requires strict Discord email verification values', function (mixed $verified) { $existingUser = User::factory()->create(['email' => 'existing@example.com']); $discordSetting = OauthSetting::create([ 'provider' => 'discord', 'client_id' => 'discord-client-id', 'client_secret' => 'discord-client-secret', 'enabled' => true, ]); expect(fn () => app(OauthLoginService::class)->login('discord', (object) [ 'email' => 'existing@example.com', 'name' => 'Discord User', 'id' => 'discord-user-id', 'user' => ['verified' => $verified], ], $discordSetting))->toThrow(HttpException::class); $this->assertGuest(); expect(OauthIdentity::where('user_id', $existingUser->id)->exists())->toBeFalse(); })->with([ 'unverified' => false, 'string true' => 'true', 'integer true' => 1, ]); it('keeps the Discord verified claim in the raw Socialite user payload', function () { $provider = (new ReflectionClass(DiscordProvider::class))->newInstanceWithoutConstructor(); $mapUser = new ReflectionMethod(DiscordProvider::class, 'mapUserToObject'); $oauthUser = $mapUser->invoke($provider, [ 'id' => 'discord-user-id', 'username' => 'Discord User', 'discriminator' => '0', 'email' => 'user@example.com', 'verified' => false, 'avatar' => null, ]); expect($oauthUser->user['verified'])->toBeFalse(); }); function mapOauthPayloadWithVendorProvider(string $providerClass, array $payload): object { $provider = (new ReflectionClass($providerClass))->newInstanceWithoutConstructor(); return (new ReflectionMethod($providerClass, 'mapUserToObject'))->invoke($provider, $payload); } it('links an existing account on the first login after upgrade from the real provider payload', function (string $provider, string $providerClass, array $payload) { $user = User::factory()->create(['email' => 'existing@example.com']); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'enabled' => true, ]); $resolvedUser = app(OauthLoginService::class)->login( $provider, mapOauthPayloadWithVendorProvider($providerClass, $payload), $setting, ); expect($resolvedUser->is($user))->toBeTrue() ->and(OauthIdentity::where(['user_id' => $user->id, 'provider' => $provider])->exists())->toBeTrue(); $this->assertAuthenticatedAs($user); })->with([ 'google userinfo v3' => ['google', GoogleProvider::class, [ 'sub' => '110169484474386276334', 'name' => 'Existing User', 'picture' => 'https://lh3.googleusercontent.com/a/photo.jpg', 'email' => 'existing@example.com', 'email_verified' => true, 'hd' => 'example.com', ]], 'azure graph me' => ['azure', AzureProvider::class, [ 'id' => '87d349ed-44d7-43e1-9a83-5f2406dee5bd', 'displayName' => 'Existing User', 'userPrincipalName' => 'Existing@example.com', 'mail' => null, ]], 'gitlab user' => ['gitlab', GitlabProvider::class, [ 'id' => 42, 'username' => 'existing', 'name' => 'Existing User', 'email' => 'existing@example.com', 'avatar_url' => null, 'confirmed_at' => '2026-01-10T09:05:22Z', ]], ]); function rerunCreatedBeforeOauthIdentitiesMigration(): void { $migration = require database_path('migrations/2026_09_29_200325_add_created_before_oauth_identities_to_users_table.php'); $migration->down(); $migration->up(); } /** * Brings the users into the state after an upgrade from v4.3.23. */ function upgradeUsersToOauthIdentities(): void { rerunCreatedBeforeOauthIdentitiesMigration(); } it('marks only password-less users that exist at upgrade time as created before OAuth identities', function () { $rootUser = User::factory()->create(['id' => 0]); $passwordUser = User::factory()->create(); $oauthCreatedUser = User::factory()->create(['password' => null]); rerunCreatedBeforeOauthIdentitiesMigration(); $newUser = User::factory()->create(['password' => null]); expect($oauthCreatedUser->refresh()->created_before_oauth_identities)->toBeTrue() ->and($rootUser->refresh()->created_before_oauth_identities)->toBeFalse() ->and($passwordUser->refresh()->created_before_oauth_identities)->toBeFalse() ->and($newUser->refresh()->created_before_oauth_identities)->toBeFalse(); }); it('does not link an unverified provider email to a password user after the upgrade', function (string $provider, array $rawClaims) { $user = User::factory()->create(['id' => 0, 'email' => 'root@example.com']); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'base_url' => 'https://auth.example.com', 'enabled' => true, ]); upgradeUsersToOauthIdentities(); expect(fn () => app(OauthLoginService::class)->login($provider, (object) [ 'email' => 'root@example.com', 'name' => 'Attacker', 'id' => 'attacker-provider-id', 'user' => $rawClaims, ], $setting))->toThrow(HttpException::class, 'OAuth provider did not verify the email address'); $this->assertGuest(); expect(OauthIdentity::count())->toBe(0) ->and($user->refresh()->created_before_oauth_identities)->toBeFalse(); })->with([ 'discord unverified' => ['discord', ['verified' => false]], 'authentik default email scope' => ['authentik', ['email_verified' => false]], ]); it('links a verified provider email to a password user after the upgrade', function (string $provider, array $rawClaims) { $user = User::factory()->create(['email' => 'member@example.com']); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'enabled' => true, ]); upgradeUsersToOauthIdentities(); $resolvedUser = app(OauthLoginService::class)->login($provider, (object) [ 'email' => 'member@example.com', 'name' => 'Member', 'id' => 'member-provider-id', 'user' => $rawClaims, ], $setting); expect($resolvedUser->is($user))->toBeTrue() ->and(OauthIdentity::where(['user_id' => $user->id, 'provider' => $provider])->exists())->toBeTrue(); $this->assertAuthenticatedAs($user); })->with([ 'github' => ['github', []], 'google verified' => ['google', ['email_verified' => true, 'hd' => 'example.com']], 'discord verified' => ['discord', ['verified' => true]], ]); it('links a password-less user from before the upgrade without an email verification claim', function () { $user = User::factory()->create(['email' => 'legacy@example.com', 'password' => null]); $setting = OauthSetting::create([ 'provider' => 'discord', 'client_id' => 'discord-client-id', 'client_secret' => 'discord-client-secret', 'enabled' => true, ]); upgradeUsersToOauthIdentities(); $resolvedUser = app(OauthLoginService::class)->login('discord', (object) [ 'email' => 'legacy@example.com', 'name' => 'Legacy User', 'id' => 'legacy-discord-id', 'user' => ['verified' => false], ], $setting); expect($resolvedUser->is($user))->toBeTrue() ->and($user->refresh()->created_before_oauth_identities)->toBeFalse(); $this->assertAuthenticatedAs($user); }); it('links a user from before the upgrade without an email verification claim', function (string $provider, array $rawClaims, ?string $password) { $user = User::factory()->create([ 'email' => 'legacy@example.com', 'password' => $password, 'created_before_oauth_identities' => true, ]); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'base_url' => 'https://auth.example.com', 'enabled' => true, ]); $resolvedUser = app(OauthLoginService::class)->login($provider, (object) [ 'email' => 'legacy@example.com', 'name' => 'Legacy User', 'id' => 'legacy-provider-id', 'user' => $rawClaims, ], $setting); expect($resolvedUser->is($user))->toBeTrue() ->and(OauthIdentity::where(['user_id' => $user->id, 'provider' => $provider])->exists())->toBeTrue() ->and($user->refresh()->created_before_oauth_identities)->toBeFalse(); $this->assertAuthenticatedAs($user); })->with([ 'discord unverified, user created by OAuth' => ['discord', ['verified' => false], null], 'authentik default email scope, user created by OAuth' => ['authentik', ['email_verified' => false], null], ]); it('does not link an unverified provider email to a flagged user who has a password', function (string $provider, array $rawClaims) { // A pre-upgrade OAuth user who set a password (for example through a password // reset) before the first OAuth login, or a password user on an instance that // ran the first version of the migration. $user = User::factory()->create([ 'email' => 'legacy@example.com', 'password' => 'password', 'created_before_oauth_identities' => true, ]); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'base_url' => 'https://auth.example.com', 'enabled' => true, ]); expect(fn () => app(OauthLoginService::class)->login($provider, (object) [ 'email' => 'legacy@example.com', 'name' => 'Attacker', 'id' => 'attacker-provider-id', 'user' => $rawClaims, ], $setting))->toThrow(HttpException::class, 'OAuth provider did not verify the email address'); expect(OauthIdentity::count())->toBe(0); $this->assertGuest(); })->with([ 'discord unverified' => ['discord', ['verified' => false]], 'authentik default email scope' => ['authentik', ['email_verified' => false]], ]); it('does not link a second provider to a user from before the upgrade', function () { $user = User::factory()->create([ 'email' => 'legacy@example.com', 'password' => null, 'created_before_oauth_identities' => true, ]); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'github', 'issuer' => 'github', 'provider_user_id' => 'github-legacy-id', 'email' => $user->email, ]); $discordSetting = OauthSetting::create([ 'provider' => 'discord', 'client_id' => 'discord-client-id', 'client_secret' => 'discord-client-secret', 'enabled' => true, ]); expect(fn () => app(OauthLoginService::class)->login('discord', (object) [ 'email' => 'legacy@example.com', 'name' => 'Attacker', 'id' => 'discord-attacker-id', 'user' => ['verified' => false], ], $discordSetting))->toThrow(HttpException::class, 'OAuth identity cannot be linked to this account'); $this->assertGuest(); expect(OauthIdentity::count())->toBe(1); }); it('does not link an existing account when the provider payload does not verify the email', function (string $provider, string $providerClass, array $payload, ?string $unverifiedEmail = null) { User::factory()->create(['email' => 'existing@example.com']); $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'enabled' => true, ]); $oauthUser = mapOauthPayloadWithVendorProvider($providerClass, $payload); if ($unverifiedEmail !== null) { $oauthUser->email = $unverifiedEmail; } expect(fn () => app(OauthLoginService::class)->login($provider, $oauthUser, $setting)) ->toThrow(HttpException::class, 'OAuth provider did not verify the email address'); $this->assertGuest(); expect(OauthIdentity::count())->toBe(0); })->with([ 'google unverified' => ['google', GoogleProvider::class, [ 'sub' => '110169484474386276334', 'name' => 'Existing User', 'picture' => null, 'email' => 'existing@example.com', 'email_verified' => false, 'hd' => 'example.com', ]], 'azure mail attribute instead of the user principal name' => ['azure', AzureProvider::class, [ 'id' => '87d349ed-44d7-43e1-9a83-5f2406dee5bd', 'displayName' => 'Existing User', 'userPrincipalName' => 'someone@other-tenant.onmicrosoft.com', 'mail' => 'existing@example.com', ], 'existing@example.com'], 'gitlab unconfirmed' => ['gitlab', GitlabProvider::class, [ 'id' => 42, 'username' => 'existing', 'name' => 'Existing User', 'email' => 'existing@example.com', 'avatar_url' => null, 'confirmed_at' => null, ]], ]); it('registers a new user from a verified provider identity', function () { InstanceSettings::findOrFail(0)->update(['is_registration_enabled' => true]); $user = app(OauthLoginService::class)->login('google', (object) [ 'email' => 'verified@example.com', 'name' => 'Verified User', 'id' => 'verified-google-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ], OauthSetting::where('provider', 'google')->firstOrFail()); expect($user->email)->toBe('verified@example.com'); expect($user->email_verified_at)->toBeNull(); $this->assertAuthenticatedAs($user); $this->assertDatabaseHas('oauth_identities', [ 'user_id' => $user->id, 'provider' => 'google', 'provider_user_id' => 'verified-google-id', ]); }); it('does not register a new user through a non-OIDC provider when registration is disabled', function (string $provider, array $rawClaims) { // Upgraded installs have allow_registration = true on every provider row (column default). OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'enabled' => true, 'allow_registration' => true, ]); expect(fn () => app(OauthLoginService::class)->login($provider, (object) [ 'email' => 'stranger@example.com', 'name' => 'Stranger', 'id' => 'stranger-id', 'user' => $rawClaims, ], OauthSetting::where('provider', $provider)->firstOrFail()))->toThrow(HttpException::class, 'Registration is disabled'); $this->assertGuest(); expect(User::count())->toBe(0) ->and(OauthIdentity::count())->toBe(0); })->with([ 'github' => ['github', []], 'google' => ['google', ['email_verified' => true, 'hd' => 'example.com']], ]); it('registers a new user through a non-OIDC provider when registration is enabled', function () { InstanceSettings::findOrFail(0)->update(['is_registration_enabled' => true]); OauthSetting::create([ 'provider' => 'github', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'enabled' => true, 'allow_registration' => false, ]); $user = app(OauthLoginService::class)->login('github', (object) [ 'email' => 'new-user@example.com', 'name' => 'New User', 'id' => 'github-new-user-id', 'user' => [], ], OauthSetting::where('provider', 'github')->firstOrFail()); expect($user->email)->toBe('new-user@example.com'); $this->assertAuthenticatedAs($user); }); it('does not link another provider identity to an account by shared email', function () { $user = User::factory()->create(['email' => 'shared@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'github', 'issuer' => 'github', 'provider_user_id' => 'github-user-id', 'email' => 'shared@example.com', ]); expect(fn () => app(OauthLoginService::class)->login('google', (object) [ 'email' => 'shared@example.com', 'name' => 'Other Provider User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ], OauthSetting::where('provider', 'google')->firstOrFail()))->toThrow(HttpException::class); $this->assertGuest(); expect(OauthIdentity::count())->toBe(1); }); it('sends an OAuth user with confirmed two factor authentication to the Fortify challenge', function () { config()->set('fortify.features', [Features::twoFactorAuthentication(['confirm' => true])]); $user = User::factory()->create([ 'email' => 'two-factor@example.com', 'two_factor_secret' => encrypt('secret'), 'two_factor_confirmed_at' => now(), ]); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'google', 'issuer' => 'google', 'provider_user_id' => 'two-factor-google-id', 'email' => $user->email, ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->once()->andReturnSelf(); $provider->shouldReceive('with')->once()->andReturnSelf(); $provider->shouldReceive('user')->once()->andReturn((object) [ 'email' => $user->email, 'name' => $user->name, 'id' => 'two-factor-google-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); $this->get(route('auth.callback', 'google')) ->assertRedirect(route('two-factor.login')) ->assertSessionHas('login.id', $user->id); $this->assertGuest(); }); it('completes OAuth login without a challenge when two factor authentication is not enabled for the user', function () { $user = User::factory()->create(['email' => 'without-two-factor@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'google', 'issuer' => 'google', 'provider_user_id' => 'plain-google-id', 'email' => $user->email, ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->once()->andReturnSelf(); $provider->shouldReceive('with')->once()->andReturnSelf(); $provider->shouldReceive('user')->once()->andReturn((object) [ 'email' => $user->email, 'name' => $user->name, 'id' => 'plain-google-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); $this->get(route('auth.callback', 'google'))->assertRedirect('/'); $this->assertAuthenticatedAs($user); }); it('rejects redirect requests for a disabled provider', function () { $this->withoutVite(); OauthSetting::where('provider', 'google')->update(['enabled' => false]); $this->get(route('auth.redirect', 'google'))->assertForbidden(); $this->assertGuest(); }); it('rejects callback requests for a disabled provider', function () { OauthSetting::where('provider', 'google')->update(['enabled' => false]); $this->from('/login')->get(route('auth.callback', 'google'))->assertRedirect('/login'); $this->assertGuest(); }); it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { config()->set('app.maintenance.driver', 'file'); InstanceSettings::firstOrCreate([ 'id' => 0, ], [ 'is_registration_enabled' => false, ])->update([ 'is_registration_enabled' => true, ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->once()->andReturnSelf(); $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); $provider->shouldReceive('user')->once()->andReturn((object) [ 'email' => $providerEmail, 'name' => 'Example User', 'id' => 'google-user-id', ]); Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); $response = $this->from('/login')->get(route('auth.callback', 'google')); $response->assertRedirect('/login'); expect(User::count())->toBe(0); })->with([ 'null email' => [null], 'blank email' => [' '], 'malformed email' => ['not-an-email'], 'missing domain' => ['user@'], ]); it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) { $oauthUser = (object) [ 'email' => 'user@example.edu', 'name' => 'Example User', ]; if ($invalidId !== 'missing') { $oauthUser->id = $invalidId; } try { app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail()); } catch (HttpException $exception) { expect($exception->getStatusCode())->toBe(403) ->and(OauthIdentity::count())->toBe(0) ->and(User::count())->toBe(0); return; } $this->fail('Expected an invalid OAuth provider user ID to be rejected.'); })->with([ 'null id' => [null], 'missing id' => ['missing'], 'blank id' => [' '], 'non-scalar id' => [[]], 'true id' => [true], 'false id' => [false], 'float id' => [1.0], ]); it('rejects a Google account outside the configured Workspace even when its email is verified', function () { $user = User::factory()->create(['email' => 'user@outside.example']); $setting = OauthSetting::where('provider', 'google')->firstOrFail(); expect(fn () => app(OauthLoginService::class)->login('google', (object) [ 'email' => $user->email, 'name' => 'Outside User', 'id' => 'google-outside-id', 'user' => ['email_verified' => true, 'hd' => 'outside.example'], ], $setting))->toThrow(HttpException::class); expect(OauthIdentity::count())->toBe(0); $this->assertGuest(); }); it('sends the instance callback url without saving it when a forged host starts an oauth login', function () { InstanceSettings::query()->whereKey(0)->update(['fqdn' => 'https://coolify.example.com']); Once::flush(); OauthSetting::create([ 'provider' => 'github', 'client_id' => 'github-client-id', 'client_secret' => 'github-client-secret', 'enabled' => true, ]); $response = $this->get('http://attacker.example/auth/github/redirect'); $response->assertRedirect(); parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); expect($query['redirect_uri'])->toBe('https://coolify.example.com/auth/github/callback') ->and(OauthSetting::where('provider', 'github')->value('redirect_uri'))->toBeNull(); }); function mockGoogleCallbackUser(array $oauthUser): void { $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->andReturnSelf(); $provider->shouldReceive('with')->andReturnSelf(); $provider->shouldReceive('user')->andReturn((object) $oauthUser); Socialite::shouldReceive('driver')->with('google')->andReturn($provider); } describe('callback error messages', function () { beforeEach(function () { config()->set('app.maintenance.driver', 'file'); }); it('tells a user that the account already uses another sign-in method', function () { $user = User::factory()->create(['email' => 'linked@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'github', 'issuer' => 'github', 'provider_user_id' => 'github-user-id', 'email' => 'linked@example.com', ]); mockGoogleCallbackUser([ 'email' => 'linked@example.com', 'name' => 'Linked User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); $this->from('/login')->get(route('auth.callback', 'google')) ->assertRedirect('/login'); expect(session('errors')->first())->toBe(__('auth.failed.oauth_already_linked')); $this->assertGuest(); }); it('tells a user that the provider did not verify the email before it reveals a linked account', function () { $user = User::factory()->create(['email' => 'linked@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'github', 'issuer' => 'github', 'provider_user_id' => 'github-user-id', 'email' => 'linked@example.com', ]); mockGoogleCallbackUser([ 'email' => 'linked@example.com', 'name' => 'Unverified User', 'id' => 'google-user-id', 'user' => ['email_verified' => false, 'hd' => 'example.com'], ]); $this->from('/login')->get(route('auth.callback', 'google')) ->assertRedirect('/login'); expect(session('errors')->first())->toBe(__('auth.failed.oauth_email_unverified')); $this->assertGuest(); }); it('tells a new user that registration is disabled', function () { mockGoogleCallbackUser([ 'email' => 'new@example.com', 'name' => 'New User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.com'], ]); $this->from('/login')->get(route('auth.callback', 'google')) ->assertRedirect('/login'); expect(session('errors')->first())->toBe(__('auth.registration_disabled')); $this->assertGuest(); }); it('shows a generic OAuth message instead of the password message for other denied logins', function () { mockGoogleCallbackUser([ 'email' => 'outside@example.org', 'name' => 'Outside User', 'id' => 'google-user-id', 'user' => ['email_verified' => true, 'hd' => 'example.org'], ]); $this->from('/login')->get(route('auth.callback', 'google')) ->assertRedirect('/login'); expect(session('errors')->first())->toBe(__('auth.failed.oauth')) ->not->toBe(__('auth.failed')); $this->assertGuest(); }); }); it('matches the Google hosted domain without case or spaces and accepts any Workspace for a wildcard', function (string $tenant, ?string $hostedDomain, bool $allowed) { $user = User::factory()->create(['email' => 'user@example.com']); $setting = OauthSetting::where('provider', 'google')->firstOrFail(); $setting->update(['tenant' => $tenant]); $claims = array_filter(['email_verified' => true, 'hd' => $hostedDomain], fn ($value) => $value !== null); $login = fn () => app(OauthLoginService::class)->login('google', (object) [ 'email' => $user->email, 'name' => 'Workspace User', 'id' => 'google-workspace-id', 'user' => $claims, ], $setting); if ($allowed) { expect($login()->is($user))->toBeTrue(); } else { expect($login)->toThrow(HttpException::class, 'Google account is not in the configured Workspace'); $this->assertGuest(); } })->with([ 'mixed-case tenant' => ['Example.com', 'example.com', true], 'tenant with spaces' => [' example.com ', 'example.com', true], 'mixed-case hd claim' => ['example.com', 'EXAMPLE.com', true], 'wildcard with a Workspace account' => ['*', 'any-company.example', true], 'wildcard with a personal account' => ['*', null, false], 'other domain' => ['example.com', 'example.org', false], 'personal account' => ['example.com', null, false], ]); it('allows OAuth user creation through global registration or the OIDC user creation setting', function (string $provider, bool $isRegistrationEnabled, bool $allowRegistration, bool $expected) { InstanceSettings::query()->whereKey(0)->update(['is_registration_enabled' => $isRegistrationEnabled]); Once::flush(); $setting = new OauthSetting(['provider' => $provider, 'allow_registration' => $allowRegistration]); expect($setting->allowsUserCreation())->toBe($expected); })->with([ 'github, registration on' => ['github', true, false, true], 'github, registration off' => ['github', false, true, false], 'oidc, registration off, oidc user creation on' => ['oidc', false, true, true], 'oidc, registration off, oidc user creation off' => ['oidc', false, false, false], 'oidc, registration on, oidc user creation off' => ['oidc', true, false, true], ]);