mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
Drop the OAuth confirm/callback flow that let users confirm destructive actions by re-authenticating through a linked provider. This removes the confirm route and session handling in OauthController, the middleware bypass for those routes, OauthSetting::providerName(), User::oauthConfirmationProviders(), OauthLoginService::identityBelongsToUser() and the socialite_reauthentication_parameters() helper. Users with a linked OAuth identity no longer need a password step and only use the dialog's typed confirmation. Password confirmation is now required only for users who have a password and no SSO identity. The confirmation modal and tests are updated to match.
321 lines
15 KiB
PHP
321 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\OauthIdentity;
|
|
use App\Models\OauthSetting;
|
|
use App\Models\User;
|
|
use App\Services\Auth\OauthIdentityIssuer;
|
|
use App\Services\Auth\OauthLoginService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Once;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
const OAUTH_REKEY_MIGRATION = 'migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php';
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate([
|
|
'id' => 0,
|
|
'is_registration_enabled' => false,
|
|
]);
|
|
|
|
Once::flush();
|
|
});
|
|
|
|
function instanceOauthSetting(string $provider, array $attributes = []): OauthSetting
|
|
{
|
|
return OauthSetting::create([
|
|
'provider' => $provider,
|
|
'client_id' => "{$provider}-client-id",
|
|
'client_secret' => "{$provider}-client-secret",
|
|
'enabled' => true,
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function linkInstanceIdentity(User $user, string $provider, string $issuer, string $providerUserId): OauthIdentity
|
|
{
|
|
return OauthIdentity::create([
|
|
'user_id' => $user->id,
|
|
'provider' => $provider,
|
|
'issuer' => $issuer,
|
|
'provider_user_id' => $providerUserId,
|
|
'email' => $user->email,
|
|
]);
|
|
}
|
|
|
|
function instanceOauthUser(string|int $id, string $email, array $rawClaims = []): object
|
|
{
|
|
return (object) [
|
|
'id' => $id,
|
|
'email' => $email,
|
|
'name' => 'Provider User',
|
|
'user' => $rawClaims,
|
|
];
|
|
}
|
|
|
|
describe('instance key', function () {
|
|
it('normalizes the base URL of self-hostable providers', function (string $provider, ?string $baseUrl, ?string $expected) {
|
|
expect(OauthIdentityIssuer::forProvider($provider, $baseUrl, null))->toBe($expected);
|
|
})->with([
|
|
'gitlab.com default' => ['gitlab', null, 'https://gitlab.com'],
|
|
'gitlab empty base url' => ['gitlab', '', 'https://gitlab.com'],
|
|
'trailing slash' => ['gitlab', 'https://gitlab.example.com/', 'https://gitlab.example.com'],
|
|
'uppercase scheme and host' => ['gitlab', 'HTTPS://GitLab.Example.COM', 'https://gitlab.example.com'],
|
|
'default https port' => ['authentik', 'https://auth.example.com:443/', 'https://auth.example.com'],
|
|
'default http port' => ['authentik', 'http://auth.example.com:80', 'http://auth.example.com'],
|
|
'custom port kept' => ['authentik', 'https://auth.example.com:8443', 'https://auth.example.com:8443'],
|
|
'path kept without trailing slash' => ['gitlab', 'https://example.com/GitLab/', 'https://example.com/GitLab'],
|
|
'query and fragment dropped' => ['zitadel', 'https://id.example.com/?a=b#c', 'https://id.example.com'],
|
|
'missing scheme' => ['clerk', 'clerk.example.com', 'https://clerk.example.com'],
|
|
'authentik without base url' => ['authentik', null, null],
|
|
'zitadel without base url' => ['zitadel', ' ', null],
|
|
]);
|
|
|
|
it('uses the lowercased Azure tenant and one stable key for multi-tenant endpoints', function (?string $tenant, string $expected) {
|
|
expect(OauthIdentityIssuer::forProvider('azure', null, $tenant))->toBe($expected);
|
|
})->with([
|
|
'tenant id' => ['1F2E3D4C-0000-4000-8000-ABCDEF123456', '1f2e3d4c-0000-4000-8000-abcdef123456'],
|
|
'tenant domain' => [' Contoso.onmicrosoft.com ', 'contoso.onmicrosoft.com'],
|
|
'common' => ['common', 'azure'],
|
|
'organizations' => ['Organizations', 'azure'],
|
|
'consumers' => ['consumers', 'azure'],
|
|
'no tenant' => [null, 'azure'],
|
|
]);
|
|
|
|
it('keeps the provider name for single SaaS providers', function (string $provider) {
|
|
expect(OauthIdentityIssuer::forProvider($provider, 'https://ignored.example.com', 'ignored.example.com'))->toBe($provider);
|
|
})->with(['github', 'bitbucket', 'discord', 'google', 'infomaniak']);
|
|
|
|
it('does not compute a key for OIDC, which uses the iss claim', function () {
|
|
expect(OauthIdentityIssuer::forProvider('oidc', 'https://idp.example.com', null))->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('login', function () {
|
|
it('does not log in as the linked user when the same subject comes from another instance', function (string $provider, ?string $linkedBaseUrl, string $otherBaseUrl) {
|
|
$victim = User::factory()->create(['email' => 'victim@example.com']);
|
|
$setting = instanceOauthSetting($provider, ['base_url' => $linkedBaseUrl]);
|
|
$verifiedClaims = ['confirmed_at' => '2026-01-01T00:00:00Z', 'email_verified' => true];
|
|
$service = app(OauthLoginService::class);
|
|
|
|
expect($service->login($provider, instanceOauthUser(1, 'victim@example.com', $verifiedClaims), $setting)->is($victim))->toBeTrue();
|
|
Auth::logout();
|
|
|
|
$setting->update(['base_url' => $otherBaseUrl]);
|
|
|
|
expect(fn () => $service->login($provider, instanceOauthUser(1, 'attacker@example.com', $verifiedClaims), $setting->refresh()))
|
|
->toThrow(HttpException::class);
|
|
|
|
expect(Auth::check())->toBeFalse()
|
|
->and(OauthIdentity::count())->toBe(1);
|
|
})->with([
|
|
'self-hosted gitlab' => ['gitlab', 'https://gitlab.example.com', 'https://gitlab.attacker.example'],
|
|
'gitlab.com to self-hosted' => ['gitlab', null, 'https://gitlab.attacker.example'],
|
|
'authentik' => ['authentik', 'https://auth.example.com', 'https://auth.attacker.example'],
|
|
'zitadel' => ['zitadel', 'https://id.example.com', 'https://id.attacker.example'],
|
|
'clerk' => ['clerk', 'https://clerk.example.com', 'https://clerk.attacker.example'],
|
|
]);
|
|
|
|
it('does not log in as the linked user with the same subject and email from another instance', function () {
|
|
$victim = User::factory()->create(['email' => 'victim@example.com']);
|
|
$setting = instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com']);
|
|
$claims = ['confirmed_at' => '2026-01-01T00:00:00Z'];
|
|
$service = app(OauthLoginService::class);
|
|
|
|
$service->login('gitlab', instanceOauthUser(1, 'victim@example.com', $claims), $setting);
|
|
Auth::logout();
|
|
$setting->update(['base_url' => 'https://gitlab.attacker.example']);
|
|
|
|
expect(fn () => $service->login('gitlab', instanceOauthUser(1, 'victim@example.com', $claims), $setting->refresh()))
|
|
->toThrow(HttpException::class);
|
|
|
|
expect(Auth::check())->toBeFalse();
|
|
});
|
|
|
|
it('logs in the linked user from the same instance when the base URL is written differently', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
linkInstanceIdentity($user, 'gitlab', 'https://gitlab.example.com', '1');
|
|
$setting = instanceOauthSetting('gitlab', ['base_url' => 'HTTPS://GitLab.Example.com:443/']);
|
|
|
|
$resolvedUser = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(1, 'user@example.com'), $setting);
|
|
|
|
expect($resolvedUser->is($user))->toBeTrue();
|
|
$this->assertAuthenticatedAs($user);
|
|
});
|
|
|
|
it('does not log in as the linked user after the Azure tenant changes', function () {
|
|
$user = User::factory()->create(['email' => 'user@contoso.com']);
|
|
$setting = instanceOauthSetting('azure', ['tenant' => 'contoso-tenant']);
|
|
$azureUser = instanceOauthUser('azure-object-id', 'user@contoso.com', ['userPrincipalName' => 'user@contoso.com']);
|
|
$service = app(OauthLoginService::class);
|
|
|
|
$service->login('azure', $azureUser, $setting);
|
|
Auth::logout();
|
|
$setting->update(['tenant' => 'other-tenant']);
|
|
|
|
expect(fn () => $service->login('azure', $azureUser, $setting->refresh()))->toThrow(HttpException::class);
|
|
|
|
expect(Auth::check())->toBeFalse();
|
|
});
|
|
|
|
it('logs in the linked Azure user from the same tenant', function () {
|
|
$user = User::factory()->create(['email' => 'user@contoso.com']);
|
|
linkInstanceIdentity($user, 'azure', 'contoso-tenant', 'azure-object-id');
|
|
$setting = instanceOauthSetting('azure', ['tenant' => 'Contoso-Tenant']);
|
|
|
|
$resolvedUser = app(OauthLoginService::class)->login('azure', instanceOauthUser('azure-object-id', 'user@contoso.com'), $setting);
|
|
|
|
expect($resolvedUser->is($user))->toBeTrue();
|
|
});
|
|
|
|
it('stores new identities under the normalized instance key', function () {
|
|
instanceSettings()->update(['is_registration_enabled' => true]);
|
|
$setting = instanceOauthSetting('gitlab', ['base_url' => 'https://GitLab.Example.com/']);
|
|
|
|
$user = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(7, 'new@example.com', [
|
|
'confirmed_at' => '2026-01-01T00:00:00Z',
|
|
]), $setting);
|
|
|
|
$this->assertDatabaseHas('oauth_identities', [
|
|
'user_id' => $user->id,
|
|
'provider' => 'gitlab',
|
|
'issuer' => 'https://gitlab.example.com',
|
|
'provider_user_id' => '7',
|
|
]);
|
|
});
|
|
|
|
it('keeps the provider name as the key for single SaaS providers', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
linkInstanceIdentity($user, 'github', 'github', '99');
|
|
$setting = instanceOauthSetting('github');
|
|
|
|
$resolvedUser = app(OauthLoginService::class)->login('github', instanceOauthUser(99, 'user@example.com'), $setting);
|
|
|
|
expect($resolvedUser->is($user))->toBeTrue()
|
|
->and(OauthIdentity::sole()->issuer)->toBe('github');
|
|
});
|
|
|
|
it('rejects login when a self-hostable provider has no base URL', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
linkInstanceIdentity($user, 'authentik', 'authentik', 'subject');
|
|
$setting = instanceOauthSetting('authentik');
|
|
|
|
expect(fn () => app(OauthLoginService::class)->login('authentik', instanceOauthUser('subject', 'user@example.com'), $setting))
|
|
->toThrow(HttpException::class);
|
|
|
|
expect(Auth::check())->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('confirmation', function () {
|
|
it('has no OAuth re-authentication route for confirming destructive actions', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
linkInstanceIdentity($user, 'gitlab', 'https://gitlab.example.com', '1');
|
|
instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com']);
|
|
|
|
expect(Route::has('auth.confirm'))->toBeFalse();
|
|
|
|
// The path falls through to the catch-all route and stays inside Coolify.
|
|
$response = $this->actingAs($user)->get('/auth/gitlab/confirm');
|
|
|
|
$response->assertRedirect();
|
|
expect($response->headers->get('Location'))->toStartWith(url('/'));
|
|
});
|
|
});
|
|
|
|
describe('backfill migration', function () {
|
|
it('rekeys non-OIDC identities to the currently configured instance', function () {
|
|
$user = User::factory()->create();
|
|
instanceOauthSetting('gitlab', ['base_url' => 'https://GitLab.Example.com:443/']);
|
|
instanceOauthSetting('authentik', ['base_url' => 'https://auth.example.com/']);
|
|
instanceOauthSetting('azure', ['tenant' => 'Contoso-Tenant']);
|
|
instanceOauthSetting('github');
|
|
instanceOauthSetting('oidc', ['base_url' => 'https://idp.example.com']);
|
|
|
|
$gitlab = linkInstanceIdentity($user, 'gitlab', 'gitlab', '1');
|
|
$authentik = linkInstanceIdentity($user, 'authentik', 'authentik', '2');
|
|
$azure = linkInstanceIdentity($user, 'azure', 'azure', '3');
|
|
$github = linkInstanceIdentity($user, 'github', 'github', '4');
|
|
$oidc = linkInstanceIdentity($user, 'oidc', 'https://idp.example.com', '5');
|
|
|
|
$migration = require database_path(OAUTH_REKEY_MIGRATION);
|
|
$migration->up();
|
|
|
|
expect($gitlab->refresh()->issuer)->toBe('https://gitlab.example.com')
|
|
->and($authentik->refresh()->issuer)->toBe('https://auth.example.com')
|
|
->and($azure->refresh()->issuer)->toBe('contoso-tenant')
|
|
->and($github->refresh()->issuer)->toBe('github')
|
|
->and($oidc->refresh()->issuer)->toBe('https://idp.example.com');
|
|
});
|
|
|
|
it('defaults GitLab identities to gitlab.com and leaves unconfigured providers untouched', function () {
|
|
$user = User::factory()->create();
|
|
instanceOauthSetting('gitlab');
|
|
instanceOauthSetting('zitadel');
|
|
|
|
$gitlab = linkInstanceIdentity($user, 'gitlab', 'gitlab', '1');
|
|
$zitadel = linkInstanceIdentity($user, 'zitadel', 'zitadel', '2');
|
|
|
|
$migration = require database_path(OAUTH_REKEY_MIGRATION);
|
|
$migration->up();
|
|
|
|
expect($gitlab->refresh()->issuer)->toBe('https://gitlab.com')
|
|
->and($zitadel->refresh()->issuer)->toBe('zitadel');
|
|
});
|
|
|
|
it('keeps existing logins working after the backfill', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
$setting = instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com']);
|
|
linkInstanceIdentity($user, 'gitlab', 'gitlab', '1');
|
|
|
|
$migration = require database_path(OAUTH_REKEY_MIGRATION);
|
|
$migration->up();
|
|
|
|
$resolvedUser = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(1, 'user@example.com'), $setting);
|
|
|
|
expect($resolvedUser->is($user))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('rekey command', function () {
|
|
it('moves identities from an old instance key to the current one', function () {
|
|
$user = User::factory()->create(['email' => 'user@example.com']);
|
|
instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.new.example']);
|
|
$identity = linkInstanceIdentity($user, 'gitlab', 'https://gitlab.old.example', '1');
|
|
$other = linkInstanceIdentity(User::factory()->create(), 'authentik', 'https://gitlab.old.example', '1');
|
|
|
|
$this->artisan('oauth:rekey', ['provider' => 'gitlab', '--from' => 'https://GitLab.old.example/', '--force' => true])
|
|
->assertSuccessful();
|
|
|
|
expect($identity->refresh()->issuer)->toBe('https://gitlab.new.example')
|
|
->and($other->refresh()->issuer)->toBe('https://gitlab.old.example');
|
|
});
|
|
|
|
it('accepts an explicit target key', function () {
|
|
$user = User::factory()->create();
|
|
instanceOauthSetting('azure', ['tenant' => 'new-tenant']);
|
|
$identity = linkInstanceIdentity($user, 'azure', 'old-tenant', '1');
|
|
|
|
$this->artisan('oauth:rekey', ['provider' => 'azure', '--from' => 'Old-Tenant', '--to' => 'Other-Tenant', '--force' => true])
|
|
->assertSuccessful();
|
|
|
|
expect($identity->refresh()->issuer)->toBe('other-tenant');
|
|
});
|
|
|
|
it('does not rekey without confirmation', function () {
|
|
$user = User::factory()->create();
|
|
instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.new.example']);
|
|
$identity = linkInstanceIdentity($user, 'gitlab', 'https://gitlab.old.example', '1');
|
|
|
|
$this->artisan('oauth:rekey', ['provider' => 'gitlab', '--from' => 'https://gitlab.old.example'])
|
|
->expectsConfirmation('Move 1 gitlab identity from https://gitlab.old.example to https://gitlab.new.example?', 'no')
|
|
->assertFailed();
|
|
|
|
expect($identity->refresh()->issuer)->toBe('https://gitlab.old.example');
|
|
});
|
|
});
|