mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
276 lines
9.8 KiB
PHP
276 lines
9.8 KiB
PHP
<?php
|
|
|
|
use App\Auth\Oidc\OidcUser;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\OauthIdentity;
|
|
use App\Models\OauthSetting;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\Auth\OauthLoginService;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Once;
|
|
use Laravel\Socialite\Facades\Socialite;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->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;
|
|
});
|
|
});
|