set('app.maintenance.driver', 'file'); InstanceSettings::forceCreate([ 'id' => 0, 'is_registration_enabled' => false, ]); Once::flush(); OauthSetting::create([ 'provider' => 'oidc', 'enabled' => true, 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'base_url' => 'https://idp.example.com', 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback', 'allow_registration' => false, ]); }); function fakeOidcProvider(array $claims = []): void { $user = (new OidcUser)->setRaw(array_merge([ 'iss' => 'https://idp.example.com', 'sub' => 'okta-user-1', 'email' => 'user@example.com', 'email_verified' => true, 'name' => 'Okta User', ], $claims))->map([ 'id' => $claims['sub'] ?? 'okta-user-1', 'name' => $claims['name'] ?? 'Okta User', 'email' => $claims['email'] ?? 'user@example.com', ]); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->andReturnSelf(); $provider->shouldReceive('user')->andReturn($user); Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); } it('logs in a user through an existing oidc identity', function () { $user = User::factory()->create(['email' => 'existing@example.com']); OauthIdentity::create([ 'user_id' => $user->id, 'provider' => 'oidc', 'issuer' => 'https://idp.example.com', 'provider_user_id' => 'okta-user-1', 'email' => 'existing@example.com', ]); fakeOidcProvider(['email' => 'existing@example.com']); $response = $this->get(route('auth.callback', 'oidc')); $response->assertRedirect('/'); $this->assertAuthenticatedAs($user); }); it('continues oidc 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('oidc', (new OidcUser)->setRaw([ 'iss' => 'https://idp.example.com', 'sub' => 'oidc-race-id', 'email' => 'race@example.com', 'email_verified' => true, 'name' => 'Race User', ])->map([ 'id' => 'oidc-race-id', 'name' => 'Race User', 'email' => 'race@example.com', ]), OauthSetting::where('provider', 'oidc')->firstOrFail()); } finally { Event::forget($eventName); } expect($resolvedUser->is($user))->toBeTrue() ->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1); $this->assertAuthenticatedAs($user); }); it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () { OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); fakeOidcProvider(['email' => 'newuser@example.com']); $response = $this->get(route('auth.callback', 'oidc')); $response->assertRedirect('/'); $user = User::whereEmail('newuser@example.com')->first(); expect($user)->not->toBeNull() ->and($user->password)->not->toBeNull(); $this->assertAuthenticatedAs($user); $this->assertDatabaseHas('oauth_identities', [ 'user_id' => $user->id, 'provider' => 'oidc', 'issuer' => 'https://idp.example.com', 'provider_user_id' => 'okta-user-1', ]); }); it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () { Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); (new User)->forceFill([ 'id' => 0, 'name' => 'Root User', 'email' => 'root@example.com', 'password' => 'password', ])->save(); OauthSetting::where('provider', 'oidc')->update([ 'allow_registration' => true, 'auto_join_root_team' => true, ]); fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']); $response = $this->get(route('auth.callback', 'oidc')); $response->assertRedirect('/'); $user = User::whereEmail('root-member@example.com')->first(); expect($user)->not->toBeNull() ->and($user->teams()->count())->toBe(1); $rootMembership = $user->teams()->where('teams.id', 0)->first(); expect($rootMembership)->not->toBeNull() ->and($rootMembership->pivot->role)->toBe('member'); $this->assertDatabaseMissing('teams', [ 'name' => "Root Member's Team", ]); expect(session('currentTeam')->id)->toBe(0); $this->assertAuthenticatedAs($user); }); it('rejects linking an unverified oidc email to an existing local account', function () { $user = User::factory()->create(['email' => 'victim@example.com']); fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]); $response = $this->from('/login')->get(route('auth.callback', 'oidc')); $response->assertRedirect('/login'); $this->assertGuest(); $this->assertDatabaseMissing('oauth_identities', [ 'user_id' => $user->id, 'provider' => 'oidc', ]); }); it('rejects new oidc users when neither normal nor provider registration is enabled', function () { fakeOidcProvider(['email' => 'blocked@example.com']); $response = $this->from('/login')->get(route('auth.callback', 'oidc')); $response->assertRedirect('/login'); expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse(); }); it('creates the root user when oidc provisions the first account', function () { Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']); $response = $this->get(route('auth.callback', 'oidc')); $response->assertRedirect('/'); $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']); $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']); expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); }); it('persists raw claims as an array on the oauth identity', function () { OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); fakeOidcProvider(['email' => 'claims@example.com']); $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); $identity = OauthIdentity::where('email', 'claims@example.com')->first(); expect($identity->raw_claims)->toBeArray() ->and($identity->raw_claims['sub'])->toBe('okta-user-1'); }); it('stores empty raw claims when the provider returns no user payload', function () { OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); $user = (new OidcUser)->setIdTokenClaims([ 'iss' => 'https://idp.example.com', 'sub' => 'okta-no-payload', 'email_verified' => true, ])->map([ 'id' => 'okta-no-payload', 'name' => 'No Payload', 'email' => 'nopayload@example.com', ]); $user->user = null; $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->andReturnSelf(); $provider->shouldReceive('user')->andReturn($user); Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); $identity = OauthIdentity::where('email', 'nopayload@example.com')->first(); expect($identity->raw_claims)->toBe([]); }); it('rejects callbacks for disabled oidc provider', function () { OauthSetting::where('provider', 'oidc')->update(['enabled' => false]); $response = $this->from('/login')->get(route('auth.callback', 'oidc')); $response->assertRedirect('/login'); }); it('logs callback failures with diagnostic context', function () { Log::spy(); $provider = Mockery::mock(); $provider->shouldReceive('setConfig')->andReturnSelf(); $provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed')); Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); $response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value'])); $response->assertRedirect('/login'); Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) { return $message === 'OAuth callback failed.' && $context['provider'] === 'oidc' && $context['exception_class'] === RuntimeException::class && $context['exception_message'] === 'Token exchange failed' && $context['has_code'] === true && $context['has_state'] === true && $context['exception'] instanceof RuntimeException; }); });